haixunMaster/lib/threads-browser/publish.ts

239 lines
7.9 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
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<PublishResult> {
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<boolean> {
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<boolean> {
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<string | null> {
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<string | null> {
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;
}
}