haixunMaster/lib/threads-browser/publish.ts

239 lines
7.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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