mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-06 07:57:09 +08:00
Import sanitized project structure and GitHub docs
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from rich.console import Console
|
||||
|
||||
from utils.config import DEBUG, Environment, get_environment
|
||||
|
||||
|
||||
console = Console()
|
||||
PLAYWRIGHT_BROWSERS_PATH = "../chrome"
|
||||
|
||||
|
||||
def _local_browser_bundle_path():
|
||||
return Path(__file__).resolve().parent / PLAYWRIGHT_BROWSERS_PATH
|
||||
|
||||
|
||||
def configure_playwright_environment():
|
||||
if os.getenv("PLAYWRIGHT_BROWSERS_PATH"):
|
||||
return
|
||||
|
||||
env = get_environment()
|
||||
if env == Environment.PACKED:
|
||||
bundle_path = Path(sys.executable).resolve().parent / PLAYWRIGHT_BROWSERS_PATH
|
||||
else:
|
||||
bundle_path = _local_browser_bundle_path()
|
||||
|
||||
if bundle_path.exists():
|
||||
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(bundle_path.resolve())
|
||||
|
||||
|
||||
async def install_browser():
|
||||
try:
|
||||
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
|
||||
console.print("[bold green]Browser install completed. Please run the command again.[/bold green]")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
console.print(f"[bold red]Browser install failed: {exc}[/bold red]")
|
||||
|
||||
|
||||
async def get_browser(GUI=False):
|
||||
configure_playwright_environment()
|
||||
|
||||
headless = not GUI
|
||||
if get_environment() == Environment.LOCAL and DEBUG:
|
||||
headless = False
|
||||
|
||||
try:
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=headless,
|
||||
args=["--disable-dev-shm-usage"],
|
||||
)
|
||||
return playwright, browser
|
||||
except Exception as exc:
|
||||
if "Executable doesn't exist" in str(exc) and get_environment() != Environment.GITHUBACTION:
|
||||
console.print("[bold red]Playwright browser is missing.[/bold red]")
|
||||
await install_browser()
|
||||
sys.exit(1)
|
||||
traceback.print_exc()
|
||||
raise
|
||||
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from core.browser import get_browser
|
||||
|
||||
|
||||
CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat"
|
||||
FRIENDS_TAB_SELECTOR = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
|
||||
TARGET_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]'
|
||||
'//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]'
|
||||
)
|
||||
SCROLLABLE_FRIENDS_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div'
|
||||
)
|
||||
NO_MORE_SELECTOR = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
|
||||
LOADING_SELECTOR = 'xpath=//div[contains(@class, "semi-spin")]'
|
||||
FIRST_FRIEND_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div/div[2]/div[2]/div/div/div[1]/div/div/div/ul/div/div/div[1]/li/div'
|
||||
)
|
||||
FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]"""
|
||||
LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"]
|
||||
|
||||
|
||||
def update_collection_progress(new_names_count, no_more_visible, scroll_moved, idle_rounds, stuck_rounds, idle_limit=5, stuck_limit=2):
|
||||
next_idle_rounds = 0 if new_names_count > 0 else idle_rounds + 1
|
||||
next_stuck_rounds = 0 if scroll_moved else stuck_rounds + 1
|
||||
should_stop = no_more_visible or next_idle_rounds >= idle_limit or next_stuck_rounds >= stuck_limit
|
||||
return should_stop, next_idle_rounds, next_stuck_rounds
|
||||
|
||||
|
||||
async def _ensure_logged_in(page):
|
||||
for selector in LOGIN_MASK_SELECTORS:
|
||||
try:
|
||||
locator = page.locator(selector).first
|
||||
if await locator.count() > 0 and await locator.is_visible():
|
||||
raise RuntimeError("账号登录已失效,请重新扫码登录")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def collect_friend_names(page):
|
||||
await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000)
|
||||
await page.locator(FRIENDS_TAB_SELECTOR).click()
|
||||
|
||||
await page.wait_for_selector(FIRST_FRIEND_SELECTOR, timeout=30000)
|
||||
await page.locator(FIRST_FRIEND_SELECTOR).click()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
found_names = []
|
||||
seen_names = set()
|
||||
idle_rounds = 0
|
||||
stuck_rounds = 0
|
||||
|
||||
while True:
|
||||
target_elements = await page.locator(TARGET_SELECTOR).all()
|
||||
new_names_count = 0
|
||||
for element in target_elements:
|
||||
try:
|
||||
name = (await element.locator(FRIEND_NAME_SELECTOR).inner_text()).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not name or name in seen_names:
|
||||
continue
|
||||
seen_names.add(name)
|
||||
found_names.append(name)
|
||||
new_names_count += 1
|
||||
|
||||
no_more = page.locator(NO_MORE_SELECTOR).first
|
||||
if await no_more.count() > 0 and await no_more.is_visible():
|
||||
return found_names
|
||||
|
||||
loading = page.locator(LOADING_SELECTOR).first
|
||||
if await loading.count() > 0 and await loading.is_visible():
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
scrollable_element = await page.locator(SCROLLABLE_FRIENDS_SELECTOR).element_handle()
|
||||
if not scrollable_element:
|
||||
if found_names:
|
||||
return found_names
|
||||
raise RuntimeError("未找到好友列表滚动容器")
|
||||
|
||||
before_top = await page.evaluate("(element) => element.scrollTop", scrollable_element)
|
||||
await page.evaluate("(element) => element.scrollTop += 800", scrollable_element)
|
||||
await asyncio.sleep(1.5)
|
||||
after_top = await page.evaluate("(element) => element.scrollTop", scrollable_element)
|
||||
|
||||
should_stop, idle_rounds, stuck_rounds = update_collection_progress(
|
||||
new_names_count=new_names_count,
|
||||
no_more_visible=False,
|
||||
scroll_moved=after_top > before_top,
|
||||
idle_rounds=idle_rounds,
|
||||
stuck_rounds=stuck_rounds,
|
||||
)
|
||||
if should_stop:
|
||||
return found_names
|
||||
|
||||
|
||||
async def fetch_account_friends(account):
|
||||
cookies = list(account.get("cookies") or [])
|
||||
if not cookies:
|
||||
raise RuntimeError("账号没有可用 cookies,请重新扫码登录")
|
||||
|
||||
playwright = browser = context = page = None
|
||||
try:
|
||||
playwright, browser = await get_browser(GUI=False)
|
||||
context = await browser.new_context()
|
||||
context.set_default_navigation_timeout(120000)
|
||||
context.set_default_timeout(120000)
|
||||
page = await context.new_page()
|
||||
|
||||
await page.goto("https://creator.douyin.com/", wait_until="domcontentloaded", timeout=60000)
|
||||
await context.add_cookies(cookies)
|
||||
await page.goto(CHAT_PAGE_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await _ensure_logged_in(page)
|
||||
friends = await collect_friend_names(page)
|
||||
return friends
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"刷新好友列表失败,请重试:{exc}") from exc
|
||||
finally:
|
||||
if page:
|
||||
await page.close()
|
||||
if context:
|
||||
await context.close()
|
||||
if browser:
|
||||
await browser.close()
|
||||
if playwright:
|
||||
await playwright.stop()
|
||||
@@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.browser import get_browser
|
||||
from utils.config import normalize_unique_id, upsert_user_account
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
READY_SELECTOR = (
|
||||
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]'
|
||||
'/div/div[2]/div/div[2]/div[1]'
|
||||
)
|
||||
XPATHS = {
|
||||
"unique_id": (
|
||||
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]'
|
||||
'/div/div[2]/div/div[2]/div[1]/div[2]/div[1]/div[3]'
|
||||
),
|
||||
"name": (
|
||||
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]'
|
||||
'/div/div[2]/div/div[2]/div[1]/div[2]/div[1]/div[1]/div[1]'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def wait_for_logged_in_identity(page, timeout_ms=300000):
|
||||
await page.wait_for_selector(READY_SELECTOR, timeout=timeout_ms)
|
||||
|
||||
unique_id_element = await page.wait_for_selector(XPATHS["unique_id"], timeout=timeout_ms)
|
||||
name_element = await page.wait_for_selector(XPATHS["name"], timeout=timeout_ms)
|
||||
|
||||
unique_id_text = await unique_id_element.inner_text()
|
||||
username = (await name_element.inner_text()).strip()
|
||||
unique_id = normalize_unique_id(unique_id_text)
|
||||
return unique_id, username
|
||||
|
||||
|
||||
async def collect_login_result(page, context, timeout_ms=300000):
|
||||
unique_id, username = await wait_for_logged_in_identity(page, timeout_ms=timeout_ms)
|
||||
cookies = await context.cookies()
|
||||
return {
|
||||
"unique_id": unique_id,
|
||||
"username": username,
|
||||
"cookies": cookies,
|
||||
}
|
||||
|
||||
|
||||
async def userLogin(targets=None):
|
||||
playwright, browser = await get_browser(GUI=True)
|
||||
try:
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
await page.goto("https://creator.douyin.com/")
|
||||
console.print("Please scan the QR code and finish logging into Douyin Creator Center.")
|
||||
|
||||
login_result = await collect_login_result(page, context)
|
||||
console.print(f"Unique ID: {login_result['unique_id']}")
|
||||
console.print(f"Name: {login_result['username']}")
|
||||
console.print(f"Cookies: found {len(login_result['cookies'])} cookies")
|
||||
|
||||
if targets is None:
|
||||
raw_targets = input(
|
||||
"Open Creator Center -> 互动管理 -> 私信管理 -> 朋友私信, then enter friend display names separated by spaces: "
|
||||
)
|
||||
targets = [target.strip() for target in raw_targets.split(" ") if target.strip()]
|
||||
|
||||
account = upsert_user_account(
|
||||
login_result["unique_id"],
|
||||
login_result["username"],
|
||||
login_result["cookies"],
|
||||
targets,
|
||||
)
|
||||
console.print(f"[bold green]Login complete. Updated account {account['username']}.[/bold green]")
|
||||
return account
|
||||
finally:
|
||||
await playwright.stop()
|
||||
await browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(userLogin())
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
core/msg_builder.py
|
||||
|
||||
Resolve configured message templates into concrete per-target messages.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import date
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from utils.config import get_config
|
||||
from utils.hitokoto import request_hitokoto
|
||||
|
||||
|
||||
FESTIVAL_WINDOW_START = date(2026, 2, 16)
|
||||
FESTIVAL_WINDOW_END = date(2026, 3, 3)
|
||||
|
||||
|
||||
def _is_holiday_mode_enabled(active_config: dict, today: date) -> bool:
|
||||
return bool(active_config.get("happyNewYear", {}).get("enabled", False)) and FESTIVAL_WINDOW_START <= today <= FESTIVAL_WINDOW_END
|
||||
|
||||
|
||||
def _render_holiday_message(active_config: dict, today: date) -> str:
|
||||
from utils.chinese_new_year_2026_mare import get_lunar_date, get_random_festival_quote
|
||||
|
||||
message = str(active_config.get("happyNewYear", {}).get("messageTemplate", "[API]"))
|
||||
if "[data]" in message:
|
||||
message = message.replace("[data]", today.strftime("%Y年%m月%d日"))
|
||||
if "[data_lunar]" in message:
|
||||
lunar_date = get_lunar_date(today)
|
||||
message = message.replace("[data_lunar]", lunar_date if lunar_date else "未知农历日期")
|
||||
if "[API]" in message:
|
||||
message = message.replace("[API]", get_random_festival_quote())
|
||||
return message.strip()
|
||||
|
||||
|
||||
def _get_message_templates(active_config: dict) -> List[str]:
|
||||
strategy = active_config.get("sendStrategy", {}) or {}
|
||||
variants = [str(item).strip() for item in strategy.get("messageVariants", []) if str(item).strip()]
|
||||
if variants:
|
||||
return variants
|
||||
return [str(active_config.get("messageTemplate", "续火花")).strip()]
|
||||
|
||||
|
||||
def _render_regular_message(template: str) -> str:
|
||||
message = template
|
||||
if "[API]" in message:
|
||||
message = message.replace("[API]", request_hitokoto())
|
||||
return message.strip()
|
||||
|
||||
|
||||
def build_message_candidates(config: Optional[dict] = None) -> List[str]:
|
||||
active_config = config or get_config()
|
||||
today = date.today()
|
||||
|
||||
if _is_holiday_mode_enabled(active_config, today):
|
||||
return [_render_holiday_message(active_config, today)]
|
||||
|
||||
candidates: List[str] = []
|
||||
for template in _get_message_templates(active_config):
|
||||
message = _render_regular_message(template)
|
||||
if message and message not in candidates:
|
||||
candidates.append(message)
|
||||
|
||||
if candidates:
|
||||
return candidates
|
||||
return ["续火花"]
|
||||
|
||||
|
||||
def _extract_previous_message(previous_messages: Optional[dict], target: str) -> str:
|
||||
if not previous_messages:
|
||||
return ""
|
||||
|
||||
previous = previous_messages.get(target, "")
|
||||
if isinstance(previous, dict):
|
||||
return str(previous.get("message", "")).strip()
|
||||
return str(previous).strip()
|
||||
|
||||
|
||||
def _choose_message(candidates: List[str], previous_message: str, last_message: str) -> str:
|
||||
filtered = [message for message in candidates if message != previous_message and message != last_message]
|
||||
if filtered:
|
||||
return random.choice(filtered)
|
||||
|
||||
filtered = [message for message in candidates if message != previous_message]
|
||||
if filtered:
|
||||
return random.choice(filtered)
|
||||
|
||||
filtered = [message for message in candidates if message != last_message]
|
||||
if filtered:
|
||||
return random.choice(filtered)
|
||||
|
||||
return random.choice(candidates)
|
||||
|
||||
|
||||
def build_message(previous_message: str = "", config: Optional[dict] = None, last_message: str = "") -> str:
|
||||
candidates = build_message_candidates(config)
|
||||
return _choose_message(candidates, previous_message.strip(), last_message.strip()).strip()
|
||||
|
||||
|
||||
def build_messages_for_targets(
|
||||
targets: List[str],
|
||||
previous_messages: Optional[dict] = None,
|
||||
config: Optional[dict] = None,
|
||||
) -> Dict[str, str]:
|
||||
active_config = config or get_config()
|
||||
strategy = active_config.get("sendStrategy", {}) or {}
|
||||
|
||||
ordered_targets = []
|
||||
seen_targets = set()
|
||||
for target in targets:
|
||||
normalized = str(target).strip()
|
||||
if not normalized or normalized in seen_targets:
|
||||
continue
|
||||
seen_targets.add(normalized)
|
||||
ordered_targets.append(normalized)
|
||||
|
||||
if strategy.get("shuffleTargets", True):
|
||||
random.shuffle(ordered_targets)
|
||||
|
||||
planned_messages: Dict[str, str] = {}
|
||||
last_message = ""
|
||||
for target in ordered_targets:
|
||||
previous_message = _extract_previous_message(previous_messages, target)
|
||||
message = build_message(previous_message=previous_message, config=active_config, last_message=last_message)
|
||||
planned_messages[target] = message
|
||||
last_message = message
|
||||
|
||||
return planned_messages
|
||||
@@ -0,0 +1,279 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from core.msg_builder import build_messages_for_targets
|
||||
from utils.config import get_userData, normalize_unique_id, repo_root, save_userData
|
||||
from utils.logger import setup_logger
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
PROTOCOL_SCRIPT = repo_root() / "core" / "protocol_sender.mjs"
|
||||
NODE_HELPER_IMAGE = "node:22-alpine"
|
||||
|
||||
|
||||
def _coerce_non_negative_int(value, default):
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return max(0, int(default))
|
||||
|
||||
|
||||
def _normalize_send_strategy(config):
|
||||
raw = config.get("sendStrategy", {}) or {}
|
||||
start_min = _coerce_non_negative_int(raw.get("accountStartDelaySecondsMin", 0), 0)
|
||||
start_max = _coerce_non_negative_int(raw.get("accountStartDelaySecondsMax", start_min), start_min)
|
||||
if start_max < start_min:
|
||||
start_max = start_min
|
||||
|
||||
message_min = _coerce_non_negative_int(raw.get("messageIntervalSecondsMin", 0), 0)
|
||||
message_max = _coerce_non_negative_int(raw.get("messageIntervalSecondsMax", message_min), message_min)
|
||||
if message_max < message_min:
|
||||
message_max = message_min
|
||||
|
||||
strategy = {
|
||||
"shuffleTargets": bool(raw.get("shuffleTargets", True)),
|
||||
"accountStartDelaySecondsMin": start_min,
|
||||
"accountStartDelaySecondsMax": start_max,
|
||||
"messageIntervalSecondsMin": message_min,
|
||||
"messageIntervalSecondsMax": message_max,
|
||||
"messageVariants": [str(item).strip() for item in raw.get("messageVariants", []) if str(item).strip()],
|
||||
}
|
||||
if os.getenv("SPARKFLOW_MANUAL_RUN") == "1":
|
||||
strategy["accountStartDelaySecondsMin"] = 0
|
||||
strategy["accountStartDelaySecondsMax"] = 0
|
||||
strategy["messageIntervalSecondsMin"] = min(strategy["messageIntervalSecondsMin"], 3)
|
||||
strategy["messageIntervalSecondsMax"] = min(strategy["messageIntervalSecondsMax"], 6)
|
||||
return strategy
|
||||
|
||||
|
||||
def _account_identity_key(account):
|
||||
normalized_unique_id = normalize_unique_id(account.get("unique_id"))
|
||||
if normalized_unique_id:
|
||||
return f"uid:{normalized_unique_id}"
|
||||
|
||||
username = str(account.get("username", "")).strip()
|
||||
if username:
|
||||
return f"user:{username}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _merge_protocol_runtime_state(accounts, result_by_username):
|
||||
changed = False
|
||||
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
all_accounts = get_userData(force_reload=True)
|
||||
accounts_by_identity = {
|
||||
identity: account
|
||||
for account in all_accounts
|
||||
for identity in [_account_identity_key(account)]
|
||||
if identity
|
||||
}
|
||||
|
||||
for account in accounts:
|
||||
target_account = accounts_by_identity.get(_account_identity_key(account))
|
||||
if not target_account:
|
||||
continue
|
||||
|
||||
result = result_by_username.get(account.get("username"))
|
||||
if not result:
|
||||
continue
|
||||
|
||||
protocol_cache = result.get("protocol_targets_cache")
|
||||
if protocol_cache is not None:
|
||||
target_account["protocol_targets_cache"] = protocol_cache
|
||||
target_account["protocol_user_id"] = result.get("userId", "")
|
||||
changed = True
|
||||
|
||||
history = dict(target_account.get("message_history") or {})
|
||||
for entry in result.get("sent", []):
|
||||
if entry.get("dryRun") or not entry.get("success", True):
|
||||
continue
|
||||
|
||||
target = str(entry.get("target", "")).strip()
|
||||
message = str(entry.get("message", "")).strip()
|
||||
if not target or not message:
|
||||
continue
|
||||
|
||||
history[target] = {
|
||||
"message": message,
|
||||
"sentAt": str(entry.get("sentAt", now_iso)),
|
||||
}
|
||||
changed = True
|
||||
|
||||
if history:
|
||||
target_account["message_history"] = history
|
||||
|
||||
if changed:
|
||||
save_userData(all_accounts)
|
||||
|
||||
|
||||
def _host_repo_root():
|
||||
candidates = [
|
||||
Path("/opt/douyin-sparkflow/DouYinSparkFlow"),
|
||||
repo_root(),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if (candidate / "core" / "protocol_sender.mjs").exists():
|
||||
return candidate
|
||||
return repo_root()
|
||||
|
||||
|
||||
def _build_protocol_command():
|
||||
node_path = shutil.which("node")
|
||||
if node_path:
|
||||
return [node_path, str(PROTOCOL_SCRIPT)], repo_root(), "local-node", str(repo_root())
|
||||
|
||||
docker_path = shutil.which("docker")
|
||||
if docker_path:
|
||||
host_repo = _host_repo_root()
|
||||
return (
|
||||
[
|
||||
docker_path,
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
f"{host_repo}:/workspace",
|
||||
"-w",
|
||||
"/workspace",
|
||||
NODE_HELPER_IMAGE,
|
||||
"node",
|
||||
"core/protocol_sender.mjs",
|
||||
],
|
||||
repo_root(),
|
||||
"docker-node-helper",
|
||||
"/workspace",
|
||||
)
|
||||
|
||||
raise RuntimeError("Neither node nor docker is available for the protocol sender")
|
||||
|
||||
|
||||
def _run_protocol_for_user(user, messages_by_target, dry_run, send_strategy):
|
||||
command, cwd, runner_label, runtime_repo_root = _build_protocol_command()
|
||||
payload = {
|
||||
"repoRoot": runtime_repo_root,
|
||||
"dryRun": dry_run,
|
||||
"account": user,
|
||||
"messagesByTarget": messages_by_target,
|
||||
"sendStrategy": send_strategy,
|
||||
}
|
||||
process = subprocess.run(
|
||||
command,
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
cwd=str(cwd),
|
||||
check=False,
|
||||
)
|
||||
|
||||
stdout = (process.stdout or "").strip()
|
||||
if not stdout:
|
||||
raise RuntimeError(
|
||||
f"protocol sender returned no output for {user.get('username', 'unknown')}: {process.stderr}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"protocol sender produced invalid JSON for {user.get('username', 'unknown')}: {stdout}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0 or not data.get("ok"):
|
||||
error_message = data.get("error") or process.stderr or "protocol sender failed"
|
||||
raise RuntimeError(
|
||||
f"{user.get('username', 'unknown')} protocol sender failed: {error_message}"
|
||||
)
|
||||
|
||||
data["runner"] = runner_label
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def run_protocol_tasks(config, accounts, message_builder):
|
||||
del message_builder
|
||||
|
||||
dry_run = bool(config.get("protocolDryRun", False))
|
||||
multi_task = bool(config.get("multiTask", True))
|
||||
concurrency = int(config.get("taskCount", 1)) if multi_task else 1
|
||||
semaphore = asyncio.Semaphore(max(concurrency, 1))
|
||||
send_strategy = _normalize_send_strategy(config)
|
||||
|
||||
async def _worker(user):
|
||||
async with semaphore:
|
||||
start_delay = random.randint(
|
||||
send_strategy["accountStartDelaySecondsMin"],
|
||||
send_strategy["accountStartDelaySecondsMax"],
|
||||
)
|
||||
if start_delay > 0:
|
||||
logger.info(
|
||||
"Delaying protocol sender for %s by %ss to avoid synchronized bursts",
|
||||
user.get("username", "unknown"),
|
||||
start_delay,
|
||||
)
|
||||
await asyncio.sleep(start_delay)
|
||||
|
||||
logger.info("Starting protocol sender for %s", user.get("username", "unknown"))
|
||||
messages_by_target = build_messages_for_targets(
|
||||
user.get("targets", []),
|
||||
previous_messages=user.get("message_history", {}),
|
||||
config=config,
|
||||
)
|
||||
logger.info(
|
||||
"Prepared %s protocol messages for %s with shuffleTargets=%s interval=%s-%ss manual_run=%s",
|
||||
len(messages_by_target),
|
||||
user.get("username", "unknown"),
|
||||
send_strategy["shuffleTargets"],
|
||||
send_strategy["messageIntervalSecondsMin"],
|
||||
send_strategy["messageIntervalSecondsMax"],
|
||||
os.getenv("SPARKFLOW_MANUAL_RUN") == "1",
|
||||
)
|
||||
result = await asyncio.to_thread(
|
||||
_run_protocol_for_user,
|
||||
user,
|
||||
messages_by_target,
|
||||
dry_run,
|
||||
send_strategy,
|
||||
)
|
||||
logger.info(
|
||||
"Protocol sender finished for %s resolved=%s unresolved=%s sent=%s",
|
||||
user.get("username", "unknown"),
|
||||
len(result.get("resolved", [])),
|
||||
len(result.get("unresolved", [])),
|
||||
len(result.get("sent", [])),
|
||||
)
|
||||
return result
|
||||
|
||||
gathered = await asyncio.gather(*(_worker(user) for user in accounts), return_exceptions=True)
|
||||
|
||||
result_by_username = {}
|
||||
failures = []
|
||||
for user, item in zip(accounts, gathered):
|
||||
if isinstance(item, Exception):
|
||||
failures.append(str(item))
|
||||
logger.error("Protocol sender failed for %s: %s", user.get("username", "unknown"), item)
|
||||
continue
|
||||
result_by_username[user.get("username")] = item
|
||||
unresolved = item.get("unresolved", [])
|
||||
if unresolved:
|
||||
logger.warning(
|
||||
"Protocol sender could not resolve %s targets for %s: %s",
|
||||
len(unresolved),
|
||||
user.get("username", "unknown"),
|
||||
[entry.get("target") for entry in unresolved],
|
||||
)
|
||||
|
||||
_merge_protocol_runtime_state(accounts, result_by_username)
|
||||
|
||||
if failures and not result_by_username:
|
||||
raise RuntimeError("; ".join(failures))
|
||||
|
||||
return [result_by_username[user.get("username")] for user in accounts if user.get("username") in result_by_username]
|
||||
@@ -0,0 +1,728 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { Blob } from "node:buffer";
|
||||
|
||||
const SDK_BUNDLES = [
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/lib-polyfill.f81f86eb.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/lib-router.5ab9ff10.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/2105.f8d74876.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/douyin_creator_data_old.2f971672.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/argus-builder-strategy.5a053c46.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7676.a4cd4900.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/4916.56c33d22.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/8198.b5c0b108.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/4168.b2e72401.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7771.d27d1891.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/6682.2a991dfb.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/361.4fc40815.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/pages-chat.6f823210.js",
|
||||
];
|
||||
|
||||
const CREATOR_CHAT_URL = "https://creator.douyin.com/creator-micro/data/following/chat";
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141 Safari/537.36";
|
||||
|
||||
function noop() {}
|
||||
|
||||
function toCookieString(cookies) {
|
||||
return (cookies || [])
|
||||
.filter((item) => item?.name && item?.value !== undefined)
|
||||
.map((item) => `${item.name}=${item.value}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function normalizeNickname(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function stableNow() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function toNonNegativeInteger(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeSendStrategy(raw = {}) {
|
||||
const intervalMin = toNonNegativeInteger(raw.messageIntervalSecondsMin, 0);
|
||||
const intervalMax = Math.max(intervalMin, toNonNegativeInteger(raw.messageIntervalSecondsMax, intervalMin));
|
||||
return {
|
||||
messageIntervalSecondsMin: intervalMin,
|
||||
messageIntervalSecondsMax: intervalMax,
|
||||
};
|
||||
}
|
||||
|
||||
function randomBetweenInclusive(min, max) {
|
||||
if (max <= min) {
|
||||
return min;
|
||||
}
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
async function readStdinJson() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (!raw) {
|
||||
throw new Error("Missing JSON payload on stdin");
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function ensureBundles(cacheDir) {
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true });
|
||||
for (const url of SDK_BUNDLES) {
|
||||
const filename = url.split("/").at(-1);
|
||||
const filePath = path.join(cacheDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download SDK bundle ${url}: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
await fs.promises.writeFile(filePath, text, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function createWebpackRequire(bundleDir, cookieString) {
|
||||
const modules = {};
|
||||
const cache = {};
|
||||
|
||||
function requireModule(id) {
|
||||
if (cache[id]) {
|
||||
return cache[id].exports;
|
||||
}
|
||||
if (!modules[id]) {
|
||||
throw new Error(`Missing webpack module ${id}`);
|
||||
}
|
||||
const module = { exports: {} };
|
||||
cache[id] = module;
|
||||
modules[id].call(module.exports, module, module.exports, requireModule);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
requireModule.d = (exports, definition) => {
|
||||
for (const key of Object.keys(definition)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(exports, key)) {
|
||||
Object.defineProperty(exports, key, {
|
||||
enumerable: true,
|
||||
get: definition[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
requireModule.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
|
||||
requireModule.r = (exports) => {
|
||||
if (typeof Symbol !== "undefined" && Symbol.toStringTag) {
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||
}
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
};
|
||||
requireModule.n = (mod) => {
|
||||
const getter = mod && mod.__esModule ? () => mod.default : () => mod;
|
||||
requireModule.d(getter, { a: getter });
|
||||
return getter;
|
||||
};
|
||||
requireModule.g = globalThis;
|
||||
requireModule.hmd = (module) => module;
|
||||
requireModule.nmd = (module) => module;
|
||||
|
||||
const chunkArray = [];
|
||||
chunkArray.push = (chunk) => Object.assign(modules, chunk[1]);
|
||||
|
||||
const fakeElement = () => ({
|
||||
style: {},
|
||||
setAttribute: noop,
|
||||
appendChild: noop,
|
||||
removeChild: noop,
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
getContext: () => ({}),
|
||||
});
|
||||
const documentRef = {
|
||||
cookie: cookieString,
|
||||
referrer: CREATOR_CHAT_URL,
|
||||
createElement: fakeElement,
|
||||
getElementsByTagName: () => [],
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
body: { appendChild: noop, removeChild: noop },
|
||||
head: { appendChild: noop, removeChild: noop },
|
||||
documentElement: { style: {} },
|
||||
};
|
||||
function XMLHttpRequestStub() {
|
||||
this.open = noop;
|
||||
this.setRequestHeader = noop;
|
||||
this.send = noop;
|
||||
}
|
||||
function WebSocketStub() {
|
||||
this.readyState = 1;
|
||||
this.send = noop;
|
||||
this.close = noop;
|
||||
}
|
||||
|
||||
const context = {
|
||||
self: { webpackChunkdouyin_creator_data: chunkArray },
|
||||
window: {},
|
||||
globalThis: null,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
Buffer,
|
||||
TextDecoder,
|
||||
TextEncoder,
|
||||
Blob,
|
||||
document: documentRef,
|
||||
navigator: {
|
||||
userAgent: USER_AGENT,
|
||||
language: "en-US",
|
||||
cookieEnabled: true,
|
||||
onLine: true,
|
||||
platform: "Linux x86_64",
|
||||
sendBeacon: undefined,
|
||||
appName: "Netscape",
|
||||
},
|
||||
location: {
|
||||
href: CREATOR_CHAT_URL,
|
||||
protocol: "https:",
|
||||
search: "",
|
||||
pathname: "/creator-micro/data/following/chat",
|
||||
hostname: "creator.douyin.com",
|
||||
},
|
||||
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
sessionStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
performance: { now: () => Date.now() },
|
||||
fetch,
|
||||
XMLHttpRequest: XMLHttpRequestStub,
|
||||
WebSocket: WebSocketStub,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
atob: (value) => Buffer.from(value, "base64").toString("binary"),
|
||||
btoa: (value) => Buffer.from(value, "binary").toString("base64"),
|
||||
crypto,
|
||||
};
|
||||
context.window = context;
|
||||
context.globalThis = context;
|
||||
|
||||
for (const entry of fs.readdirSync(bundleDir).filter((name) => name.endsWith(".js")).sort()) {
|
||||
const code = fs.readFileSync(path.join(bundleDir, entry), "utf8");
|
||||
try {
|
||||
vm.runInNewContext(code, context, { filename: entry });
|
||||
} catch {
|
||||
// Some bundles execute browser-only entrypoints after registering modules.
|
||||
}
|
||||
}
|
||||
|
||||
return requireModule;
|
||||
}
|
||||
|
||||
class ProtocolError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = "ProtocolError";
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCookieMap(cookies) {
|
||||
const items = {};
|
||||
for (const item of cookies || []) {
|
||||
if (item?.name) {
|
||||
items[item.name] = item.value ?? "";
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildCreatorHeaders(cookieString, cookieMap, referer = CREATOR_CHAT_URL) {
|
||||
return {
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: referer,
|
||||
Origin: "https://creator.douyin.com",
|
||||
Accept: "application/json, text/javascript",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Cookie: cookieString,
|
||||
"x-tt-passport-csrf-token":
|
||||
cookieMap.passport_csrf_token || cookieMap.passport_csrf_token_default || "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildImHeaders(cookieString) {
|
||||
return {
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: CREATOR_CHAT_URL,
|
||||
Origin: "https://creator.douyin.com",
|
||||
Cookie: cookieString,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const timeoutMs = options.timeoutMs || 15000;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
clearTimeout(timer);
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return { response, text, data };
|
||||
}
|
||||
|
||||
async function fetchSessionIdentity(cookieString, cookieMap) {
|
||||
const headers = buildCreatorHeaders(cookieString, cookieMap);
|
||||
const params = new URLSearchParams({
|
||||
aid: "2906",
|
||||
app_name: "aweme_creator_platform",
|
||||
device_platform: "web",
|
||||
referer: "",
|
||||
user_agent: USER_AGENT,
|
||||
cookie_enabled: "true",
|
||||
screen_width: "1280",
|
||||
screen_height: "720",
|
||||
browser_language: "en-US@posix",
|
||||
browser_platform: "Linux x86_64",
|
||||
browser_name: "Mozilla",
|
||||
browser_version: USER_AGENT,
|
||||
browser_online: "true",
|
||||
timezone_name: "Asia/Shanghai",
|
||||
});
|
||||
const { response, data, text } = await fetchJson(
|
||||
`https://creator.douyin.com/aweme/v1/creator/im/user_token/?${params.toString()}`,
|
||||
{ headers },
|
||||
);
|
||||
if (!response.ok || data?.status_code !== 0 || !data?.user_id) {
|
||||
throw new ProtocolError("Failed to resolve creator IM session identity", {
|
||||
status: response.status,
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
return {
|
||||
userId: String(data.user_id),
|
||||
sessionToken: String(data.token || ""),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchIdentitySecurityToken(cookieString, cookieMap) {
|
||||
const headers = buildCreatorHeaders(cookieString, cookieMap);
|
||||
const params = new URLSearchParams({
|
||||
scene: "im_send_msg",
|
||||
auto_retry_req: "0",
|
||||
skip_verify: "0",
|
||||
identity_token_force_get_tag: "0",
|
||||
passport_jssdk_version: "5.1.4",
|
||||
passport_jssdk_type: "lite",
|
||||
is_from_ttaccountsdk: "1",
|
||||
aid: "2906",
|
||||
language: "zh",
|
||||
account_app_language: "en-US",
|
||||
id_token_version: "2.1.5",
|
||||
});
|
||||
const { response, data, text } = await fetchJson(
|
||||
`https://creator.douyin.com/passport/safe/get_identity_security_token/?${params.toString()}`,
|
||||
{ headers },
|
||||
);
|
||||
if (!response.ok || data?.message !== "success" || !data?.data?.identity_security_token) {
|
||||
throw new ProtocolError("Failed to resolve identity security token", {
|
||||
status: response.status,
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
return {
|
||||
identitySecurityHeader: JSON.stringify({ token: data.data.identity_security_token }),
|
||||
realDeviceId: String(data.data.device_id || ""),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProfileNickname(cookieString, secUid) {
|
||||
const url =
|
||||
"https://www.douyin.com/aweme/v1/web/user/profile/other/?" +
|
||||
new URLSearchParams({ sec_user_id: secUid }).toString();
|
||||
const { response, data, text } = await fetchJson(url, {
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: `https://www.douyin.com/user/${secUid}`,
|
||||
Cookie: cookieString,
|
||||
Accept: "application/json, text/javascript",
|
||||
},
|
||||
});
|
||||
if (!response.ok || data?.status_code !== 0) {
|
||||
return "";
|
||||
}
|
||||
return normalizeNickname(data?.user?.nickname);
|
||||
}
|
||||
|
||||
function stringifyMaybeLong(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value.toString === "function" && value.toString !== Object.prototype.toString) {
|
||||
return value.toString();
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function selectPeerParticipant(conversation, selfUserId) {
|
||||
const participants = conversation?.firstPageParticipant?.participants || [];
|
||||
for (const participant of participants) {
|
||||
const currentUserId = stringifyMaybeLong(participant?.user_id);
|
||||
if (currentUserId && currentUserId !== selfUserId) {
|
||||
return participant;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createProtocolClient({ bundleDir, cookieString, cookieMap, userId }) {
|
||||
const requireModule = createWebpackRequire(bundleDir, cookieString);
|
||||
const sdk = requireModule(61724);
|
||||
const { BytedIM } = requireModule(26440);
|
||||
|
||||
class AdditionalParamsPlugin extends sdk.BasePlugin {
|
||||
install() {}
|
||||
|
||||
async sendPacket(packet) {
|
||||
packet.device_id = 0;
|
||||
packet.device_platform = "douyin_creator";
|
||||
packet.headers = {
|
||||
...(packet.headers || {}),
|
||||
aid_new: 2906,
|
||||
app_name: "douyin_creator",
|
||||
};
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
|
||||
class NodeHttpClient extends sdk.IMHttpClient {
|
||||
async send(url, method, body) {
|
||||
const fullUrl = /^https?:/i.test(url)
|
||||
? url
|
||||
: `${String(this.option.apiUrl).replace(/\/$/, "")}/${String(url).replace(/^\//, "")}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 20000);
|
||||
const response = await fetch(fullUrl, {
|
||||
method,
|
||||
headers: this.headers,
|
||||
body: body ? Buffer.from(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
sendByBeacon() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new BytedIM(
|
||||
{
|
||||
appId: 2906,
|
||||
fpId: 9,
|
||||
appKey: "e1bd35ec9db7b8d846de66ed140b1ad9",
|
||||
service: 5,
|
||||
apiUrl: "https://imapi.douyin.com",
|
||||
frontierUrl: "wss://frontier-im.douyin.com/ws/v2",
|
||||
inboxType: 1,
|
||||
token: "",
|
||||
userId,
|
||||
deviceId: userId,
|
||||
authType: sdk.im_proto.AuthType.SESSION_AUTH,
|
||||
devicePlatform: "douyin_pc",
|
||||
timeout: 20000,
|
||||
acceptIncorrectInboxType: true,
|
||||
biz: "douyin_creator",
|
||||
withCredentials: false,
|
||||
httpHeaders: buildImHeaders(cookieString),
|
||||
headers: {},
|
||||
webSocketLevel: sdk.WebSocketLevel.PushOnly,
|
||||
debug: false,
|
||||
http: (ctx) => new NodeHttpClient(ctx),
|
||||
},
|
||||
[AdditionalParamsPlugin],
|
||||
);
|
||||
|
||||
const initResult = await client.init();
|
||||
if (initResult !== sdk.InitResult.Succeeded) {
|
||||
throw new ProtocolError("Protocol IM init did not succeed", { initResult });
|
||||
}
|
||||
|
||||
return { client };
|
||||
}
|
||||
|
||||
async function buildConversationCache({
|
||||
client,
|
||||
selfUserId,
|
||||
cookieString,
|
||||
existingCache = [],
|
||||
targetNames = [],
|
||||
}) {
|
||||
const cachedBySecUid = new Map(
|
||||
(existingCache || []).filter((entry) => entry?.secUid).map((entry) => [entry.secUid, entry]),
|
||||
);
|
||||
const wantedTargets = new Set((targetNames || []).map(normalizeNickname).filter(Boolean));
|
||||
const matchedTargets = new Set();
|
||||
const conversations = await client.getConversationListOnline();
|
||||
const cacheEntries = [];
|
||||
|
||||
for (const conversation of conversations) {
|
||||
if (conversation?.type !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const peer = selectPeerParticipant(conversation, selfUserId);
|
||||
if (!peer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerUserId = stringifyMaybeLong(peer.user_id);
|
||||
const secUid = peer.sec_uid || "";
|
||||
if (!peerUserId || !secUid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nickname = normalizeNickname(cachedBySecUid.get(secUid)?.nickname);
|
||||
if (!nickname) {
|
||||
try {
|
||||
nickname = await fetchProfileNickname(cookieString, secUid);
|
||||
} catch {
|
||||
nickname = "";
|
||||
}
|
||||
}
|
||||
|
||||
cacheEntries.push({
|
||||
nickname,
|
||||
peerUserId,
|
||||
secUid,
|
||||
conversationId: conversation.id,
|
||||
conversationShortId: conversation.shortId,
|
||||
updatedAt: stableNow(),
|
||||
});
|
||||
|
||||
if (nickname && wantedTargets.has(nickname)) {
|
||||
matchedTargets.add(nickname);
|
||||
if (matchedTargets.size === wantedTargets.size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = new Map();
|
||||
for (const entry of existingCache || []) {
|
||||
if (!entry?.nickname || !entry?.secUid) {
|
||||
continue;
|
||||
}
|
||||
deduped.set(entry.secUid, entry);
|
||||
}
|
||||
for (const entry of cacheEntries) {
|
||||
if (!entry.nickname) {
|
||||
continue;
|
||||
}
|
||||
deduped.set(entry.secUid, entry);
|
||||
}
|
||||
return Array.from(deduped.values()).sort((left, right) =>
|
||||
left.nickname.localeCompare(right.nickname, "zh-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetLookup(cacheEntries) {
|
||||
const byNickname = new Map();
|
||||
for (const entry of cacheEntries) {
|
||||
const key = normalizeNickname(entry.nickname);
|
||||
if (key && !byNickname.has(key)) {
|
||||
byNickname.set(key, entry);
|
||||
}
|
||||
}
|
||||
return byNickname;
|
||||
}
|
||||
|
||||
async function sendMessages({
|
||||
client,
|
||||
cacheEntries,
|
||||
messagesByTarget,
|
||||
dryRun,
|
||||
cookieString,
|
||||
cookieMap,
|
||||
sendStrategy,
|
||||
}) {
|
||||
if (!dryRun) {
|
||||
const identity = await fetchIdentitySecurityToken(cookieString, cookieMap);
|
||||
client.updateSendMessageHeaders({
|
||||
identity_security_token: identity.identitySecurityHeader,
|
||||
identity_security_device_id: identity.realDeviceId,
|
||||
identity_security_aid: "2906",
|
||||
});
|
||||
}
|
||||
|
||||
const byNickname = buildTargetLookup(cacheEntries);
|
||||
const resolved = [];
|
||||
const unresolved = [];
|
||||
const sent = [];
|
||||
const normalizedStrategy = normalizeSendStrategy(sendStrategy);
|
||||
|
||||
for (const [target, message] of Object.entries(messagesByTarget)) {
|
||||
const mapping = byNickname.get(normalizeNickname(target));
|
||||
if (!mapping) {
|
||||
unresolved.push({ target, reason: "conversation_not_found" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const conversation = client.getConversation({ conversationId: mapping.conversationId });
|
||||
if (!conversation) {
|
||||
unresolved.push({ target, reason: "conversation_not_loaded", mapping });
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.push({
|
||||
target,
|
||||
nickname: mapping.nickname,
|
||||
peerUserId: mapping.peerUserId,
|
||||
conversationId: mapping.conversationId,
|
||||
conversationShortId: mapping.conversationShortId,
|
||||
});
|
||||
|
||||
let delayBeforeSendSeconds = 0;
|
||||
if (!dryRun && sent.length > 0 && normalizedStrategy.messageIntervalSecondsMax > 0) {
|
||||
delayBeforeSendSeconds = randomBetweenInclusive(
|
||||
normalizedStrategy.messageIntervalSecondsMin,
|
||||
normalizedStrategy.messageIntervalSecondsMax,
|
||||
);
|
||||
if (delayBeforeSendSeconds > 0) {
|
||||
await sleep(delayBeforeSendSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ text: message, aweType: 774 });
|
||||
const messageObject = await client.createMessage({
|
||||
type: 7,
|
||||
content: payload,
|
||||
conversation,
|
||||
insert: false,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
sent.push({
|
||||
target,
|
||||
dryRun: true,
|
||||
message,
|
||||
payload,
|
||||
conversationId: mapping.conversationId,
|
||||
delayBeforeSendSeconds,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sendResult = await client.sendMessage({ message: messageObject });
|
||||
sent.push({
|
||||
target,
|
||||
dryRun: false,
|
||||
message,
|
||||
success: Boolean(sendResult?.success),
|
||||
statusCode: sendResult?.statusCode ?? null,
|
||||
statusMsg: sendResult?.statusMsg ?? "",
|
||||
conversationId: mapping.conversationId,
|
||||
delayBeforeSendSeconds,
|
||||
sentAt: stableNow(),
|
||||
});
|
||||
}
|
||||
|
||||
return { resolved, unresolved, sent };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const payload = await readStdinJson();
|
||||
const repoRoot = payload.repoRoot || process.cwd();
|
||||
const bundleDir = path.join(repoRoot, ".im_sdk_cache");
|
||||
await ensureBundles(bundleDir);
|
||||
|
||||
const account = payload.account || {};
|
||||
const cookieString = toCookieString(account.cookies);
|
||||
const cookieMap = extractCookieMap(account.cookies);
|
||||
const { userId } = await fetchSessionIdentity(cookieString, cookieMap);
|
||||
const { client } = await createProtocolClient({
|
||||
bundleDir,
|
||||
cookieString,
|
||||
cookieMap,
|
||||
userId,
|
||||
});
|
||||
|
||||
const cacheEntries = await buildConversationCache({
|
||||
client,
|
||||
selfUserId: userId,
|
||||
cookieString,
|
||||
existingCache: account.protocol_targets_cache || [],
|
||||
targetNames: Object.keys(payload.messagesByTarget || {}),
|
||||
});
|
||||
const execution = await sendMessages({
|
||||
client,
|
||||
cacheEntries,
|
||||
messagesByTarget: payload.messagesByTarget || {},
|
||||
dryRun: Boolean(payload.dryRun),
|
||||
cookieString,
|
||||
cookieMap,
|
||||
sendStrategy: payload.sendStrategy || {},
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
username: account.username || "",
|
||||
userId,
|
||||
dryRun: Boolean(payload.dryRun),
|
||||
protocol_targets_cache: cacheEntries,
|
||||
...execution,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
error: error?.message || String(error),
|
||||
details: error?.details || {},
|
||||
stack: error?.stack || "",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from core.browser import get_browser
|
||||
from core.msg_builder import build_message
|
||||
from core.protocol_dispatch import run_protocol_tasks
|
||||
from utils.config import get_config, get_userData, normalize_unique_id, save_userData
|
||||
from utils.logger import setup_logger
|
||||
|
||||
|
||||
config = get_config()
|
||||
user_data = get_userData()
|
||||
logger = setup_logger(level=logging.DEBUG)
|
||||
debug_artifacts_dir = Path("logs/debug_artifacts")
|
||||
debug_artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def retry_operation(name, operation, retries=3, delay=2, *args, **kwargs):
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return await operation(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
if attempt < retries - 1:
|
||||
logger.warning("%s failed, retry %s/%s: %s", name, attempt + 1, retries, exc)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.error("%s failed after %s attempts: %s", name, retries, exc)
|
||||
raise
|
||||
|
||||
|
||||
def _safe_name(value):
|
||||
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value)[:80]
|
||||
|
||||
|
||||
async def save_debug_artifacts(page, account_name, target_name, stage):
|
||||
if not get_config(force_reload=True).get("saveDebugArtifacts", False):
|
||||
return
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
stem = f"{timestamp}-{_safe_name(account_name)}-{_safe_name(target_name)}-{stage}"
|
||||
screenshot_path = debug_artifacts_dir / f"{stem}.png"
|
||||
html_path = debug_artifacts_dir / f"{stem}.html"
|
||||
|
||||
await page.screenshot(path=str(screenshot_path), full_page=True)
|
||||
html_path.write_text(await page.content(), encoding="utf-8")
|
||||
logger.info("Saved debug artifacts at stage=%s for %s/%s", stage, account_name, target_name)
|
||||
|
||||
|
||||
async def locate_chat_input(page):
|
||||
selectors = [
|
||||
"xpath=//div[contains(@class, 'chat-input-dccKiL')]//div[@contenteditable='true']",
|
||||
"xpath=//div[@contenteditable='true' and @role='textbox']",
|
||||
"xpath=(//div[@contenteditable='true'])[last()]",
|
||||
]
|
||||
|
||||
last_error = None
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector).first
|
||||
try:
|
||||
await locator.wait_for(state="visible", timeout=10000)
|
||||
await locator.click(timeout=5000)
|
||||
return locator, selector
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError(f"Unable to locate chat input, last error: {last_error}")
|
||||
|
||||
|
||||
async def read_chat_input_text(chat_input):
|
||||
try:
|
||||
return await chat_input.evaluate(
|
||||
"""(node) => {
|
||||
const raw = node.innerText ?? node.textContent ?? "";
|
||||
return raw.trim();
|
||||
}"""
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def confirm_message_sent(page, chat_input, message):
|
||||
await asyncio.sleep(2)
|
||||
|
||||
input_text = await read_chat_input_text(chat_input)
|
||||
if not input_text:
|
||||
return True, "chat input cleared"
|
||||
|
||||
first_line = message.split("\n")[0].strip()
|
||||
if first_line:
|
||||
try:
|
||||
bubble = page.locator(f"text={first_line}").last
|
||||
if await bubble.count() > 0:
|
||||
return True, "message bubble located"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False, f"chat input still contains: {input_text!r}"
|
||||
|
||||
|
||||
async def scroll_and_select_user(page, account_name, targets):
|
||||
friends_tab_selector = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
|
||||
target_selector = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]'
|
||||
'//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]'
|
||||
)
|
||||
scrollable_friends_selector = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div'
|
||||
)
|
||||
no_more_selector = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
|
||||
loading_selector = 'xpath=//div[contains(@class, "semi-spin")]'
|
||||
first_friend_selector = (
|
||||
'xpath=//*[@id="sub-app"]/div/div/div[2]/div[2]/div/div/div[1]/div/div/div/ul/div/div/div[1]/li/div'
|
||||
)
|
||||
|
||||
logger.debug("Account %s is opening the friends tab", account_name)
|
||||
await page.wait_for_selector(friends_tab_selector)
|
||||
await page.locator(friends_tab_selector).click()
|
||||
|
||||
await page.wait_for_selector(first_friend_selector)
|
||||
await page.locator(first_friend_selector).click()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
found_usernames = set()
|
||||
remaining_targets = set(targets)
|
||||
|
||||
while True:
|
||||
target_elements = await page.locator(target_selector).all()
|
||||
|
||||
for element in target_elements:
|
||||
try:
|
||||
span = element.locator("""xpath=.//span[contains(@class, "item-header-name-")]""")
|
||||
target_name = await span.inner_text()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if target_name in found_usernames:
|
||||
continue
|
||||
found_usernames.add(target_name)
|
||||
logger.debug("Account %s found friend entry %s", account_name, target_name)
|
||||
|
||||
if target_name in targets:
|
||||
await element.click()
|
||||
logger.info("Account %s selected target friend %s", account_name, target_name)
|
||||
yield target_name
|
||||
|
||||
remaining_targets.discard(target_name)
|
||||
if not remaining_targets:
|
||||
logger.info("Account %s found all target friends", account_name)
|
||||
return
|
||||
break
|
||||
else:
|
||||
if await page.locator(no_more_selector).count() > 0:
|
||||
logger.warning("Account %s reached the end of the friend list. Missing targets: %s", account_name, sorted(remaining_targets))
|
||||
return
|
||||
|
||||
if await page.locator(loading_selector).count() > 0:
|
||||
logger.debug("Account %s is waiting for more friends to load", account_name)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
scrollable_element = await page.locator(scrollable_friends_selector).element_handle()
|
||||
if not scrollable_element:
|
||||
raise RuntimeError(f"Account {account_name} could not find the friend list scroll container")
|
||||
|
||||
await page.evaluate("(element) => element.scrollTop += 800", scrollable_element)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
|
||||
def _is_manual_run():
|
||||
return os.getenv("SPARKFLOW_MANUAL_RUN") == "1"
|
||||
|
||||
|
||||
def _schedule_timezone():
|
||||
timezone_name = (
|
||||
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
|
||||
or str(os.getenv("TZ") or "").strip()
|
||||
or "Asia/Shanghai"
|
||||
)
|
||||
try:
|
||||
return ZoneInfo(timezone_name)
|
||||
except Exception:
|
||||
if timezone_name == "Asia/Shanghai":
|
||||
logger.warning("Falling back to fixed UTC+8 because %r is unavailable", timezone_name)
|
||||
return timezone(timedelta(hours=8), name="Asia/Shanghai")
|
||||
logger.warning("Falling back to system timezone because %r is unavailable", timezone_name)
|
||||
return datetime.now().astimezone().tzinfo
|
||||
|
||||
|
||||
def _normalize_send_window(config):
|
||||
raw = config.get("dailySendWindow", {}) or {}
|
||||
normalized = {
|
||||
"enabled": bool(raw.get("enabled", False)),
|
||||
"startHour": int(raw.get("startHour", 10)),
|
||||
"endHour": int(raw.get("endHour", 18)),
|
||||
"scheduleIntervalMinutes": max(1, int(raw.get("scheduleIntervalMinutes", 10))),
|
||||
}
|
||||
if normalized["startHour"] < 0 or normalized["startHour"] > 23:
|
||||
normalized["enabled"] = False
|
||||
if normalized["endHour"] < 1 or normalized["endHour"] > 24:
|
||||
normalized["enabled"] = False
|
||||
if normalized["endHour"] <= normalized["startHour"]:
|
||||
normalized["enabled"] = False
|
||||
if bool(raw.get("enabled", False)) and not normalized["enabled"]:
|
||||
logger.warning("Invalid dailySendWindow=%s, disabling windowed sending for this run", raw)
|
||||
return normalized
|
||||
|
||||
|
||||
def _account_identity(user):
|
||||
return str(user.get("unique_id") or user.get("username") or "unknown").strip()
|
||||
|
||||
|
||||
def _parse_sent_at(raw_value, local_tz):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=local_tz)
|
||||
return parsed.astimezone(local_tz)
|
||||
|
||||
|
||||
def _target_sent_today(user, target_name, now):
|
||||
history = dict(user.get("message_history") or {})
|
||||
entry = history.get(target_name) or {}
|
||||
sent_at = _parse_sent_at(entry.get("sentAt"), now.tzinfo)
|
||||
return bool(sent_at and sent_at.date() == now.date())
|
||||
|
||||
|
||||
def _scheduled_send_time(user, target_name, send_window, now):
|
||||
window_minutes = (send_window["endHour"] - send_window["startHour"]) * 60
|
||||
start_of_window = now.replace(
|
||||
hour=send_window["startHour"],
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
seed = f"{now.date().isoformat()}|{_account_identity(user)}|{target_name}"
|
||||
digest = hashlib.sha256(seed.encode("utf-8")).digest()
|
||||
offset_minutes = int.from_bytes(digest[:8], "big") % window_minutes
|
||||
return start_of_window + timedelta(minutes=offset_minutes)
|
||||
|
||||
|
||||
def _select_due_targets(user, send_window, now):
|
||||
targets = list(user.get("targets") or [])
|
||||
if not send_window.get("enabled") or _is_manual_run():
|
||||
return targets, [], []
|
||||
|
||||
window_start = now.replace(
|
||||
hour=send_window["startHour"],
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
window_end = now.replace(
|
||||
hour=send_window["endHour"],
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
if now < window_start or now > window_end:
|
||||
return [], [], [(target, _scheduled_send_time(user, target, send_window, now)) for target in targets]
|
||||
|
||||
due_targets = []
|
||||
already_sent = []
|
||||
pending_targets = []
|
||||
for target_name in targets:
|
||||
if _target_sent_today(user, target_name, now):
|
||||
already_sent.append(target_name)
|
||||
continue
|
||||
scheduled_at = _scheduled_send_time(user, target_name, send_window, now)
|
||||
if now >= scheduled_at:
|
||||
due_targets.append(target_name)
|
||||
else:
|
||||
pending_targets.append((target_name, scheduled_at))
|
||||
return due_targets, already_sent, pending_targets
|
||||
|
||||
|
||||
def _prepare_active_users_for_run(active_config, active_user_data):
|
||||
if _is_manual_run():
|
||||
logger.info("SPARKFLOW_MANUAL_RUN=1, bypassing daily send window")
|
||||
return [dict(user, targets=list(user.get("targets") or [])) for user in active_user_data]
|
||||
|
||||
send_window = _normalize_send_window(active_config)
|
||||
if not send_window.get("enabled"):
|
||||
return [dict(user, targets=list(user.get("targets") or [])) for user in active_user_data]
|
||||
|
||||
schedule_tz = _schedule_timezone()
|
||||
now = datetime.now(schedule_tz)
|
||||
logger.info(
|
||||
"dailySendWindow enabled startHour=%s endHour=%s intervalMinutes=%s timezone=%s now=%s",
|
||||
send_window["startHour"],
|
||||
send_window["endHour"],
|
||||
send_window["scheduleIntervalMinutes"],
|
||||
getattr(schedule_tz, "key", str(schedule_tz)),
|
||||
now.isoformat(timespec="seconds"),
|
||||
)
|
||||
|
||||
runnable_users = []
|
||||
for user in active_user_data:
|
||||
due_targets, already_sent, pending_targets = _select_due_targets(user, send_window, now)
|
||||
pending_preview = [
|
||||
f"{target_name}@{scheduled_at.strftime('%H:%M')}"
|
||||
for target_name, scheduled_at in pending_targets[:5]
|
||||
]
|
||||
logger.info(
|
||||
"windowed user=%s dueTargets=%s alreadySentToday=%s pendingTargets=%s",
|
||||
user.get("username", "unknown"),
|
||||
due_targets,
|
||||
already_sent,
|
||||
pending_preview,
|
||||
)
|
||||
if due_targets:
|
||||
runnable_user = dict(user)
|
||||
runnable_user["targets"] = due_targets
|
||||
runnable_users.append(runnable_user)
|
||||
|
||||
if not runnable_users:
|
||||
logger.info("No targets are due for the current windowed run")
|
||||
return runnable_users
|
||||
|
||||
|
||||
def _account_match_tokens(user):
|
||||
tokens = set()
|
||||
username = str(user.get("username") or "").strip()
|
||||
unique_id = str(user.get("unique_id") or "").strip()
|
||||
normalized_unique_id = normalize_unique_id(unique_id)
|
||||
if username:
|
||||
tokens.add(username.lower())
|
||||
if unique_id:
|
||||
tokens.add(unique_id.lower())
|
||||
if normalized_unique_id:
|
||||
tokens.add(normalized_unique_id.lower())
|
||||
return tokens
|
||||
|
||||
|
||||
def _persist_browser_send_success(user, target_name, message, sent_at):
|
||||
target_username = str(user.get("username") or "").strip()
|
||||
target_unique_id = normalize_unique_id(user.get("unique_id"))
|
||||
if not target_username and not target_unique_id:
|
||||
logger.warning("Cannot persist browser send history without account identity for target=%s", target_name)
|
||||
return
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
matched_account = None
|
||||
for account in accounts:
|
||||
account_username = str(account.get("username") or "").strip()
|
||||
account_unique_id = normalize_unique_id(account.get("unique_id"))
|
||||
if target_unique_id and account_unique_id == target_unique_id:
|
||||
matched_account = account
|
||||
break
|
||||
if target_username and account_username == target_username:
|
||||
matched_account = account
|
||||
break
|
||||
|
||||
if matched_account is None:
|
||||
logger.warning(
|
||||
"Could not find account to persist browser send history for user=%s target=%s",
|
||||
target_username or target_unique_id or "unknown",
|
||||
target_name,
|
||||
)
|
||||
return
|
||||
|
||||
history = dict(matched_account.get("message_history") or {})
|
||||
history[target_name] = {
|
||||
"message": message,
|
||||
"sentAt": sent_at,
|
||||
}
|
||||
matched_account["message_history"] = history
|
||||
save_userData(accounts)
|
||||
|
||||
user_history = dict(user.get("message_history") or {})
|
||||
user_history[target_name] = {
|
||||
"message": message,
|
||||
"sentAt": sent_at,
|
||||
}
|
||||
user["message_history"] = user_history
|
||||
|
||||
logger.info(
|
||||
"Persisted browser send history for %s/%s at %s",
|
||||
matched_account.get("username", "unknown"),
|
||||
target_name,
|
||||
sent_at,
|
||||
)
|
||||
|
||||
|
||||
def _split_sender_modes(active_config, runnable_user_data):
|
||||
if not active_config.get("useProtocolSender", True):
|
||||
return [], runnable_user_data
|
||||
|
||||
browser_sender_accounts = {
|
||||
str(item).strip().lower()
|
||||
for item in (active_config.get("browserSenderAccounts") or [])
|
||||
if str(item).strip()
|
||||
}
|
||||
if not browser_sender_accounts:
|
||||
return runnable_user_data, []
|
||||
|
||||
protocol_users = []
|
||||
browser_users = []
|
||||
for user in runnable_user_data:
|
||||
if _account_match_tokens(user) & browser_sender_accounts:
|
||||
browser_users.append(user)
|
||||
else:
|
||||
protocol_users.append(user)
|
||||
return protocol_users, browser_users
|
||||
|
||||
|
||||
async def run_browser_tasks(active_config, browser_user_data):
|
||||
if not browser_user_data:
|
||||
return
|
||||
|
||||
playwright, browser = await get_browser()
|
||||
try:
|
||||
semaphore = asyncio.Semaphore(active_config["taskCount"] if active_config["multiTask"] else 1)
|
||||
tasks = []
|
||||
for user in browser_user_data:
|
||||
logger.info("Using browser sender for user=%s targets=%s", user.get("username", "unknown"), user["targets"])
|
||||
tasks.append(do_user_task(browser, user, semaphore))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
finally:
|
||||
await playwright.stop()
|
||||
await browser.close()
|
||||
|
||||
|
||||
async def do_user_task(browser, user, semaphore):
|
||||
async with semaphore:
|
||||
account_name = user.get("username", "unknown")
|
||||
cookies = user["cookies"]
|
||||
targets = user["targets"]
|
||||
context = await browser.new_context()
|
||||
context.set_default_navigation_timeout(120000)
|
||||
context.set_default_timeout(120000)
|
||||
|
||||
try:
|
||||
page = await context.new_page()
|
||||
await retry_operation(
|
||||
"open creator home",
|
||||
page.goto,
|
||||
retries=3,
|
||||
delay=5,
|
||||
url="https://creator.douyin.com/",
|
||||
)
|
||||
await context.add_cookies(cookies)
|
||||
await retry_operation(
|
||||
"open chat page",
|
||||
page.goto,
|
||||
retries=3,
|
||||
delay=5,
|
||||
url="https://creator.douyin.com/creator-micro/data/following/chat",
|
||||
)
|
||||
|
||||
logger.info("Account %s started the message flow", account_name)
|
||||
async for target_name in scroll_and_select_user(page, account_name, targets):
|
||||
try:
|
||||
await save_debug_artifacts(page, account_name, target_name, "selected-friend")
|
||||
chat_input, selector_used = await locate_chat_input(page)
|
||||
logger.info("Using chat input selector %s for %s/%s", selector_used, account_name, target_name)
|
||||
|
||||
message = build_message()
|
||||
logger.info("Prepared message for %s/%s: %r", account_name, target_name, message)
|
||||
|
||||
lines = message.split("\n")
|
||||
for index, line in enumerate(lines):
|
||||
await chat_input.type(line, delay=50)
|
||||
if index < len(lines) - 1:
|
||||
await chat_input.press("Shift+Enter")
|
||||
|
||||
await save_debug_artifacts(page, account_name, target_name, "typed-message")
|
||||
|
||||
logger.info("Pressing Enter to send message for %s/%s", account_name, target_name)
|
||||
await chat_input.press("Enter")
|
||||
|
||||
sent_ok, detail = await confirm_message_sent(page, chat_input, message)
|
||||
await save_debug_artifacts(page, account_name, target_name, "after-send")
|
||||
|
||||
if not sent_ok:
|
||||
raise RuntimeError(detail)
|
||||
|
||||
logger.info("Message send confirmed for %s/%s: %s", account_name, target_name, detail)
|
||||
_persist_browser_send_success(
|
||||
user,
|
||||
target_name,
|
||||
message,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Send flow failed for %s/%s", account_name, target_name)
|
||||
await save_debug_artifacts(page, account_name, target_name, "send-error")
|
||||
raise
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
|
||||
async def runTasks():
|
||||
active_config = get_config(force_reload=True)
|
||||
all_user_data = get_userData(force_reload=True)
|
||||
active_user_data = [user for user in all_user_data if user.get("enabled", True)]
|
||||
disabled_user_data = [user for user in all_user_data if not user.get("enabled", True)]
|
||||
|
||||
logger.info("Starting tasks with config")
|
||||
logger.info("multiTask=%s taskCount=%s", active_config["multiTask"], active_config["taskCount"])
|
||||
logger.info("messageTemplate=%s", active_config["messageTemplate"])
|
||||
logger.info("sendStrategy=%s", active_config.get("sendStrategy", {}))
|
||||
logger.info("hitokotoTypes=%s", active_config["hitokotoTypes"])
|
||||
logger.info("enabledUsers=%s disabledUsers=%s", len(active_user_data), len(disabled_user_data))
|
||||
for user in active_user_data:
|
||||
logger.info("user=%s targets=%s", user.get("username", "unknown"), user["targets"])
|
||||
for user in disabled_user_data:
|
||||
logger.info("skipping disabled user=%s", user.get("username", "unknown"))
|
||||
|
||||
if not active_user_data:
|
||||
logger.warning("No enabled accounts are available for the task run")
|
||||
return
|
||||
|
||||
runnable_user_data = _prepare_active_users_for_run(active_config, active_user_data)
|
||||
if not runnable_user_data:
|
||||
return
|
||||
|
||||
with task_run_lock():
|
||||
protocol_user_data, browser_user_data = _split_sender_modes(active_config, runnable_user_data)
|
||||
if protocol_user_data:
|
||||
await run_protocol_tasks(active_config, protocol_user_data, build_message)
|
||||
await run_browser_tasks(active_config, browser_user_data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def task_run_lock():
|
||||
lock_path = Path("logs/task.run.lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
handle = lock_path.open("x", encoding="utf-8")
|
||||
except FileExistsError as exc:
|
||||
raise RuntimeError("another task run is already in progress") from exc
|
||||
|
||||
try:
|
||||
handle.write(f"{os.getpid()}\n")
|
||||
handle.flush()
|
||||
yield
|
||||
finally:
|
||||
handle.close()
|
||||
try:
|
||||
lock_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
Reference in New Issue
Block a user