haixunMaster/lib/threads-browser/compose.ts

361 lines
11 KiB
TypeScript

import type { Locator, Page } from "playwright";
import { humanDelay } from "@/lib/utils";
import { shouldRunHeadedForPublish } from "./debug";
import type { DebugRun } from "./debug";
import { captureDebugStep } from "./debug";
export type ComposeBlockReason =
| "login_redirect"
| "instagram_reauth"
| "onboarding_dialog"
| "editor_not_found";
export interface ComposeReadyResult {
ready: boolean;
reason?: ComposeBlockReason;
message?: string;
}
const IG_CONTINUE_TEXT = "使用 Instagram 帳號繼續登入";
const COMPOSE_PLACEHOLDERS = [
/有什麼新鮮事/,
/有什麼新鮮/,
/在想什麼/,
/What's new/i,
/Start a thread/i,
/串文|貼文|文字/,
];
const COMPOSE_URLS = [
"https://www.threads.com/",
"https://www.threads.com/compose",
"https://www.threads.com/intent/post",
];
export async function dismissBlockingDialogs(page: Page, debug?: DebugRun | null) {
const actions: Array<() => Promise<void>> = [
async () => {
const close = page.getByRole("button", { name: "關閉" }).first();
if (await close.isVisible({ timeout: 800 }).catch(() => false)) await close.click({ force: true });
},
async () => {
const close = page.locator('[aria-label="關閉"], [aria-label="Close"]').first();
if (await close.isVisible({ timeout: 800 }).catch(() => false)) await close.click({ force: true });
},
async () => {
const later = page.getByRole("button", { name: /稍後|Not now|取消|Cancel/i }).first();
if (await later.isVisible({ timeout: 800 }).catch(() => false)) await later.click({ force: true });
},
async () => page.keyboard.press("Escape"),
];
for (const action of actions) {
try {
await action();
await humanDelay(300, 600);
} catch {
// ignore
}
}
await captureDebugStep(page, debug, "dismiss-dialogs");
}
export async function assessComposeReady(page: Page): Promise<ComposeReadyResult> {
const url = page.url();
if (url.includes("/login")) {
return {
ready: false,
reason: "login_redirect",
message: "Session 已失效,請到設定頁重新登入 Threads",
};
}
const igContinue = page.getByText(IG_CONTINUE_TEXT, { exact: false }).first();
if (await igContinue.isVisible({ timeout: 1500 }).catch(() => false)) {
return {
ready: false,
reason: "instagram_reauth",
message:
"Threads 需要完成 Instagram 授權才能發文。請到設定頁重新登入,並在瀏覽器完成「使用 Instagram 帳號繼續登入」。",
};
}
const dialog = page.locator('[role="dialog"]').first();
if (await dialog.isVisible({ timeout: 1000 }).catch(() => false)) {
const text = await dialog.innerText().catch(() => "");
if (text.includes("加入 Threads") || text.includes("暢所欲言")) {
return {
ready: false,
reason: "onboarding_dialog",
message:
"Threads 顯示未完成設定的彈窗,無法發文。請到設定頁重新登入並完成 Instagram 授權。",
};
}
}
return { ready: true };
}
async function tryResolveInstagramReauth(page: Page, debug?: DebugRun | null): Promise<boolean> {
const igContinue = page.getByText(IG_CONTINUE_TEXT, { exact: false }).first();
if (!(await igContinue.isVisible({ timeout: 2000 }).catch(() => false))) {
return true;
}
await captureDebugStep(page, debug, "instagram-reauth-detected");
if (!(await shouldRunHeadedForPublish())) {
return false;
}
await igContinue.click().catch(() => undefined);
await humanDelay(2000, 3500);
await captureDebugStep(page, debug, "instagram-reauth-clicked");
const deadline = Date.now() + 180000;
while (Date.now() < deadline) {
if (page.url().includes("instagram.com")) {
await page.waitForTimeout(2000);
continue;
}
if (!page.url().includes("threads")) {
await page.waitForTimeout(2000);
continue;
}
await dismissBlockingDialogs(page, debug);
const ready = await assessComposeReady(page);
if (ready.ready) return true;
await page.waitForTimeout(2000);
}
return false;
}
export async function openComposeSurface(page: Page, debug?: DebugRun | null): Promise<ComposeReadyResult> {
for (const url of COMPOSE_URLS) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => undefined);
await humanDelay(2000, 3500);
await captureDebugStep(page, debug, "compose-nav", { url });
let ready = await assessComposeReady(page);
if (!ready.ready && ready.reason === "instagram_reauth") {
const resolved = await tryResolveInstagramReauth(page, debug);
if (!resolved) return ready;
ready = await assessComposeReady(page);
}
if (!ready.ready) continue;
await dismissBlockingDialogs(page, debug);
ready = await assessComposeReady(page);
if (!ready.ready) continue;
if (url.endsWith("/")) {
const opened = await clickCreateButton(page);
await humanDelay(2000, 3500);
await captureDebugStep(page, debug, "after-create-click", { opened });
}
const editor = await findComposeEditor(page, debug, 10000);
if (editor) {
return { ready: true };
}
}
return {
ready: false,
reason: "editor_not_found",
message: composeFailureMessage("editor_not_found"),
};
}
async function clickCreateButton(page: Page): Promise<boolean> {
const clicked = await page.evaluate(() => {
const labels = ["建立", "Create", "新串文", "New thread"];
for (const label of labels) {
const marker =
document.querySelector(`[aria-label="${label}"]`) ??
document.querySelector(`[title="${label}"]`);
if (!marker) continue;
let node: Element | null = marker;
for (let depth = 0; depth < 12 && node; depth++) {
if (node instanceof HTMLAnchorElement && node.href) {
node.click();
return true;
}
if (node instanceof HTMLElement) {
const role = node.getAttribute("role");
const tabIndex = node.getAttribute("tabindex");
if (role === "button" || role === "link" || tabIndex === "0") {
node.click();
return true;
}
}
node = node.parentElement;
}
if (marker instanceof HTMLElement) {
marker.click();
return true;
}
}
return false;
});
if (clicked) return true;
const createSelectors = [
'a[href*="/compose"]',
'a[href*="intent/post"]',
'[aria-label="建立"]',
'[aria-label="Create"]',
'div[role="button"]:has-text("建立")',
'div[role="button"]:has-text("Create")',
];
for (const selector of createSelectors) {
const target = page.locator(selector).first();
if (await target.isVisible({ timeout: 1200 }).catch(() => false)) {
await target.click({ force: true });
return true;
}
}
return false;
}
export async function findComposeEditor(
page: Page,
debug?: DebugRun | null,
timeoutMs = 18000
): Promise<Locator | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const editor = await locateComposeEditor(page);
if (editor) {
await captureDebugStep(page, debug, "editor-found");
return editor;
}
await page.waitForTimeout(600);
}
await captureDebugStep(page, debug, "editor-not-found", {
textboxCount: await page.getByRole("textbox").count(),
contentEditableCount: await page.locator('[contenteditable="true"]').count(),
dialogCount: await page.locator('[role="dialog"]').count(),
url: page.url(),
});
return null;
}
async function locateComposeEditor(page: Page): Promise<Locator | null> {
const selectors = [
'[role="dialog"] [contenteditable="true"][role="textbox"]',
'[role="dialog"] [contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
'[contenteditable="true"][aria-label*="文字"]',
'[contenteditable="true"][aria-label*="text"]',
'[contenteditable="true"][aria-label*="串文"]',
'[contenteditable="true"][aria-label*="thread"]',
'[data-lexical-editor="true"]',
'div[role="textbox"]',
'[contenteditable="true"]',
"textarea",
];
for (const selector of selectors) {
const locator = page.locator(selector);
const count = await locator.count();
for (let i = 0; i < count; i++) {
const candidate = locator.nth(i);
if (await isUsableEditor(candidate)) {
return candidate;
}
}
}
for (const pattern of COMPOSE_PLACEHOLDERS) {
const byPlaceholder = page.getByPlaceholder(pattern).first();
if (await byPlaceholder.isVisible({ timeout: 400 }).catch(() => false)) {
return byPlaceholder;
}
}
const textboxes = await page.getByRole("textbox").all();
for (const textbox of textboxes) {
if (!(await textbox.isVisible().catch(() => false))) continue;
const editable = await textbox.getAttribute("contenteditable");
const aria = (await textbox.getAttribute("aria-label")) ?? "";
const placeholder = (await textbox.getAttribute("placeholder")) ?? "";
if (
editable === "true" ||
/文字|text|串文|貼文|compose|thread/i.test(aria + placeholder)
) {
return textbox;
}
}
const found = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll('[contenteditable="true"], [role="textbox"]'));
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue;
const rect = node.getBoundingClientRect();
if (rect.width < 80 || rect.height < 20) continue;
const style = window.getComputedStyle(node);
if (style.visibility === "hidden" || style.display === "none") continue;
node.setAttribute("data-threadtools-editor", "true");
return true;
}
return false;
});
if (found) {
const marked = page.locator('[data-threadtools-editor="true"]').first();
if (await marked.count()) return marked;
}
return null;
}
async function isUsableEditor(candidate: Locator): Promise<boolean> {
if (!(await candidate.isVisible({ timeout: 400 }).catch(() => false))) return false;
const box = await candidate.boundingBox().catch(() => null);
if (!box || box.width < 60 || box.height < 16) return false;
return true;
}
export async function probeComposeEditor(page: Page, debug?: DebugRun | null): Promise<ComposeReadyResult> {
const result = await openComposeSurface(page, debug);
if (!result.ready) return result;
const editor = await findComposeEditor(page, debug, 8000);
if (!editor) {
return {
ready: false,
reason: "editor_not_found",
message: composeFailureMessage("editor_not_found"),
};
}
await dismissBlockingDialogs(page, debug);
return { ready: true };
}
export function composeFailureMessage(reason?: ComposeBlockReason): string {
switch (reason) {
case "instagram_reauth":
return "Threads 需要完成 Instagram 授權。請到設定頁重新登入,並在彈出的瀏覽器完成 Instagram 登入。";
case "login_redirect":
return "Session 已失效,請到設定頁重新登入";
case "onboarding_dialog":
return "Threads 有未完成設定的彈窗擋住發文,請重新登入並完成設定";
case "editor_not_found":
default:
return "找不到撰寫欄位。請開啟 Debug 模式查看瀏覽器,或到設定頁重新登入並完成 Instagram 授權。";
}
}