Import sanitized project structure and GitHub docs

This commit is contained in:
Rixuan Shao
2026-05-17 18:28:25 +08:00
parent d7e6bb1628
commit de4f3aeb74
87 changed files with 16696 additions and 1 deletions
View File
+63
View File
@@ -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
+134
View File
@@ -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()
+83
View File
@@ -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())
+129
View File
@@ -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
+279
View File
@@ -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]
+728
View File
@@ -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);
});
+556
View File
@@ -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