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> = [ 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 授權。"; } }