239 lines
7.9 KiB
TypeScript
239 lines
7.9 KiB
TypeScript
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;
|
||
}
|
||
} |