import { humanDelay, THREADS_MAX_CHARS } from "@/lib/utils"; import { attachImages } from "./attach-media"; import { typeIntoEditor } from "./format-text"; import { composeFailureMessage, findComposeEditor, openComposeSurface, } from "./compose"; import { withPage } from "./browser"; import { createDebugRun, captureDebugStep, shouldRunHeadedForPublish, type DebugRun } from "./debug"; import { browserSessionOptions, type ActiveSession } from "./session"; import type { PublishResult } from "./types"; export async function publish( session: ActiveSession, text: string, imagePaths: string[] = [] ): Promise { if (text.length > THREADS_MAX_CHARS) { return { success: false, error: `貼文超過 ${THREADS_MAX_CHARS} 字上限` }; } const debugRun = await createDebugRun("publish"); const headed = await shouldRunHeadedForPublish(); return withPage( session.storageState, async (page) => { const compose = await openComposeSurface(page, debugRun); if (!compose.ready) { await captureDebugStep(page, debugRun, "compose-blocked", { ...compose }); return { success: false, error: compose.message ?? composeFailureMessage(compose.reason), debugRunId: debugRun?.id, }; } const editor = await findComposeEditor(page, debugRun); if (!editor) { return { success: false, error: composeFailureMessage("editor_not_found"), debugRunId: debugRun?.id, }; } if (imagePaths.length > 0) { let imageAttached = await attachImages(page, imagePaths, debugRun); let textAlreadyEntered = false; if (!imageAttached) { await typeIntoEditor(page, editor, text); textAlreadyEntered = true; await captureDebugStep(page, debugRun, "text-entered-before-image", { length: text.length, }); imageAttached = await attachImages(page, imagePaths, debugRun); } if (!imageAttached) { return { success: false, error: "無法附加配圖。建議使用 JPG/PNG,或開啟 Debug 模式查看上傳步驟。", debugRunId: debugRun?.id, }; } await humanDelay(1200, 2000); if (!textAlreadyEntered) { const editorAfterImage = (await findComposeEditor(page, debugRun, 8000)) ?? editor; await typeIntoEditor(page, editorAfterImage, text); } await captureDebugStep(page, debugRun, "text-entered", { length: text.length }); await humanDelay(800, 1500); } else { await editor.click(); await humanDelay(300, 600); await typeIntoEditor(page, editor, text); await captureDebugStep(page, debugRun, "text-entered", { length: text.length }); await humanDelay(800, 1500); } const posted = await clickPublishButton(page); await captureDebugStep(page, debugRun, "post-click", { posted }); if (!posted) { return { success: false, error: "找不到可點擊的發佈按鈕", debugRunId: debugRun?.id, }; } const confirmed = await waitForPublishComplete(page, imagePaths.length > 0); if (!confirmed) { return { success: false, error: "發佈可能未完成,請到 Threads 確認是否已出現貼文", debugRunId: debugRun?.id, }; } await humanDelay(1500, 2500); const permalink = (await extractLatestPermalink(page)) ?? (session.username ? await extractPermalinkFromProfile(page, text, session.username, debugRun) : null); await captureDebugStep(page, debugRun, "publish-success", { permalink }); return { success: true, permalink: permalink ?? undefined, debugRunId: debugRun?.id }; }, { ...browserSessionOptions(session), headless: !headed } ); } async function clickPublishButton(page: import("playwright").Page): Promise { const scopes = [page.locator('[role="dialog"]'), page.locator("body")]; const postSelectors = [ '[aria-label="發佈"]', '[aria-label="發布"]', '[aria-label="Post"]', 'div[role="button"]:has-text("發佈")', 'div[role="button"]:has-text("發布")', 'div[role="button"]:has-text("Post")', 'button:has-text("發佈")', 'button:has-text("發布")', 'button:has-text("Post")', ]; for (const scope of scopes) { for (const selector of postSelectors) { const buttons = scope.locator(selector); const count = await buttons.count(); for (let i = count - 1; i >= 0; i--) { const btn = buttons.nth(i); if (!(await btn.isVisible({ timeout: 1200 }).catch(() => false))) continue; const disabled = await btn.getAttribute("aria-disabled"); if (disabled === "true") continue; const label = (await btn.innerText().catch(() => "")).trim(); if (!label || label.length > 12) continue; await btn.click(); return true; } } } return false; } async function waitForPublishComplete( page: import("playwright").Page, hasImage = false ): Promise { await humanDelay(hasImage ? 4000 : 2500, hasImage ? 6500 : 4000); const errorToast = page .locator("text=/發佈失敗|發布失敗|無法發佈|couldn't post|failed to post/i") .first(); if (await errorToast.isVisible({ timeout: 2000 }).catch(() => false)) { return false; } const dialog = page.locator('[role="dialog"]'); if ((await dialog.count()) > 0) { const closed = await dialog .first() .waitFor({ state: "hidden", timeout: hasImage ? 30000 : 18000 }) .then(() => true) .catch(() => false); if (closed) return true; } const composeEditor = page.locator('[role="dialog"] [contenteditable="true"]').first(); if ((await composeEditor.count()) > 0) { const content = await composeEditor.innerText().catch(() => ""); if (content.trim().length < 3) return true; } if (!page.url().includes("/compose") && !page.url().includes("intent/post")) return true; const toast = page.locator("text=/已發佈|已發布|posted|Post published/i").first(); return toast.isVisible({ timeout: 5000 }).catch(() => false); } async function extractLatestPermalink(page: import("playwright").Page): Promise { try { const link = page.locator('a[href*="/post/"]').first(); const href = await link.getAttribute("href", { timeout: 5000 }); if (!href) return null; return href.startsWith("http") ? href : `https://www.threads.com${href}`; } catch { return null; } } async function extractPermalinkFromProfile( page: import("playwright").Page, text: string, username: string, debug?: DebugRun | null ): Promise { const snippet = text.trim().slice(0, 48); if (!snippet) return null; try { await page.goto(`https://www.threads.com/@${username}`, { waitUntil: "domcontentloaded", timeout: 30000, }); await humanDelay(2000, 3000); await captureDebugStep(page, debug, "profile-check"); const postLinks = page.locator('a[href*="/post/"]'); const count = Math.min(await postLinks.count(), 8); for (let i = 0; i < count; i++) { const link = postLinks.nth(i); const container = link.locator( "xpath=ancestor::*[contains(@data-pressable-container,'true')][1]" ); const scope = (await container.count()) > 0 ? container : link; const body = await scope.innerText().catch(() => ""); if (body.includes(snippet.slice(0, 24))) { const href = await link.getAttribute("href"); if (href) return href.startsWith("http") ? href : `https://www.threads.com${href}`; } } const fallback = page.locator('a[href*="/post/"]').first(); const href = await fallback.getAttribute("href", { timeout: 5000 }); if (!href) return null; return href.startsWith("http") ? href : `https://www.threads.com${href}`; } catch { return null; } }