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;
|
|||
|
|
}
|
|||
|
|
}
|