361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import type { Locator, Page } from "playwright";
|
|
import { humanDelay } from "@/lib/utils";
|
|
import { shouldRunHeadedForPublish } from "./debug";
|
|
import type { DebugRun } from "./debug";
|
|
import { captureDebugStep } from "./debug";
|
|
|
|
export type ComposeBlockReason =
|
|
| "login_redirect"
|
|
| "instagram_reauth"
|
|
| "onboarding_dialog"
|
|
| "editor_not_found";
|
|
|
|
export interface ComposeReadyResult {
|
|
ready: boolean;
|
|
reason?: ComposeBlockReason;
|
|
message?: string;
|
|
}
|
|
|
|
const IG_CONTINUE_TEXT = "使用 Instagram 帳號繼續登入";
|
|
const COMPOSE_PLACEHOLDERS = [
|
|
/有什麼新鮮事/,
|
|
/有什麼新鮮/,
|
|
/在想什麼/,
|
|
/What's new/i,
|
|
/Start a thread/i,
|
|
/串文|貼文|文字/,
|
|
];
|
|
|
|
const COMPOSE_URLS = [
|
|
"https://www.threads.com/",
|
|
"https://www.threads.com/compose",
|
|
"https://www.threads.com/intent/post",
|
|
];
|
|
|
|
export async function dismissBlockingDialogs(page: Page, debug?: DebugRun | null) {
|
|
const actions: Array<() => Promise<void>> = [
|
|
async () => {
|
|
const close = page.getByRole("button", { name: "關閉" }).first();
|
|
if (await close.isVisible({ timeout: 800 }).catch(() => false)) await close.click({ force: true });
|
|
},
|
|
async () => {
|
|
const close = page.locator('[aria-label="關閉"], [aria-label="Close"]').first();
|
|
if (await close.isVisible({ timeout: 800 }).catch(() => false)) await close.click({ force: true });
|
|
},
|
|
async () => {
|
|
const later = page.getByRole("button", { name: /稍後|Not now|取消|Cancel/i }).first();
|
|
if (await later.isVisible({ timeout: 800 }).catch(() => false)) await later.click({ force: true });
|
|
},
|
|
async () => page.keyboard.press("Escape"),
|
|
];
|
|
|
|
for (const action of actions) {
|
|
try {
|
|
await action();
|
|
await humanDelay(300, 600);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
await captureDebugStep(page, debug, "dismiss-dialogs");
|
|
}
|
|
|
|
export async function assessComposeReady(page: Page): Promise<ComposeReadyResult> {
|
|
const url = page.url();
|
|
if (url.includes("/login")) {
|
|
return {
|
|
ready: false,
|
|
reason: "login_redirect",
|
|
message: "Session 已失效,請到設定頁重新登入 Threads",
|
|
};
|
|
}
|
|
|
|
const igContinue = page.getByText(IG_CONTINUE_TEXT, { exact: false }).first();
|
|
if (await igContinue.isVisible({ timeout: 1500 }).catch(() => false)) {
|
|
return {
|
|
ready: false,
|
|
reason: "instagram_reauth",
|
|
message:
|
|
"Threads 需要完成 Instagram 授權才能發文。請到設定頁重新登入,並在瀏覽器完成「使用 Instagram 帳號繼續登入」。",
|
|
};
|
|
}
|
|
|
|
const dialog = page.locator('[role="dialog"]').first();
|
|
if (await dialog.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
const text = await dialog.innerText().catch(() => "");
|
|
if (text.includes("加入 Threads") || text.includes("暢所欲言")) {
|
|
return {
|
|
ready: false,
|
|
reason: "onboarding_dialog",
|
|
message:
|
|
"Threads 顯示未完成設定的彈窗,無法發文。請到設定頁重新登入並完成 Instagram 授權。",
|
|
};
|
|
}
|
|
}
|
|
|
|
return { ready: true };
|
|
}
|
|
|
|
async function tryResolveInstagramReauth(page: Page, debug?: DebugRun | null): Promise<boolean> {
|
|
const igContinue = page.getByText(IG_CONTINUE_TEXT, { exact: false }).first();
|
|
if (!(await igContinue.isVisible({ timeout: 2000 }).catch(() => false))) {
|
|
return true;
|
|
}
|
|
|
|
await captureDebugStep(page, debug, "instagram-reauth-detected");
|
|
|
|
if (!(await shouldRunHeadedForPublish())) {
|
|
return false;
|
|
}
|
|
|
|
await igContinue.click().catch(() => undefined);
|
|
await humanDelay(2000, 3500);
|
|
await captureDebugStep(page, debug, "instagram-reauth-clicked");
|
|
|
|
const deadline = Date.now() + 180000;
|
|
while (Date.now() < deadline) {
|
|
if (page.url().includes("instagram.com")) {
|
|
await page.waitForTimeout(2000);
|
|
continue;
|
|
}
|
|
|
|
if (!page.url().includes("threads")) {
|
|
await page.waitForTimeout(2000);
|
|
continue;
|
|
}
|
|
|
|
await dismissBlockingDialogs(page, debug);
|
|
const ready = await assessComposeReady(page);
|
|
if (ready.ready) return true;
|
|
await page.waitForTimeout(2000);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export async function openComposeSurface(page: Page, debug?: DebugRun | null): Promise<ComposeReadyResult> {
|
|
for (const url of COMPOSE_URLS) {
|
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => undefined);
|
|
await humanDelay(2000, 3500);
|
|
await captureDebugStep(page, debug, "compose-nav", { url });
|
|
|
|
let ready = await assessComposeReady(page);
|
|
if (!ready.ready && ready.reason === "instagram_reauth") {
|
|
const resolved = await tryResolveInstagramReauth(page, debug);
|
|
if (!resolved) return ready;
|
|
ready = await assessComposeReady(page);
|
|
}
|
|
if (!ready.ready) continue;
|
|
|
|
await dismissBlockingDialogs(page, debug);
|
|
ready = await assessComposeReady(page);
|
|
if (!ready.ready) continue;
|
|
|
|
if (url.endsWith("/")) {
|
|
const opened = await clickCreateButton(page);
|
|
await humanDelay(2000, 3500);
|
|
await captureDebugStep(page, debug, "after-create-click", { opened });
|
|
}
|
|
|
|
const editor = await findComposeEditor(page, debug, 10000);
|
|
if (editor) {
|
|
return { ready: true };
|
|
}
|
|
}
|
|
|
|
return {
|
|
ready: false,
|
|
reason: "editor_not_found",
|
|
message: composeFailureMessage("editor_not_found"),
|
|
};
|
|
}
|
|
|
|
async function clickCreateButton(page: Page): Promise<boolean> {
|
|
const clicked = await page.evaluate(() => {
|
|
const labels = ["建立", "Create", "新串文", "New thread"];
|
|
for (const label of labels) {
|
|
const marker =
|
|
document.querySelector(`[aria-label="${label}"]`) ??
|
|
document.querySelector(`[title="${label}"]`);
|
|
if (!marker) continue;
|
|
|
|
let node: Element | null = marker;
|
|
for (let depth = 0; depth < 12 && node; depth++) {
|
|
if (node instanceof HTMLAnchorElement && node.href) {
|
|
node.click();
|
|
return true;
|
|
}
|
|
if (node instanceof HTMLElement) {
|
|
const role = node.getAttribute("role");
|
|
const tabIndex = node.getAttribute("tabindex");
|
|
if (role === "button" || role === "link" || tabIndex === "0") {
|
|
node.click();
|
|
return true;
|
|
}
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
|
|
if (marker instanceof HTMLElement) {
|
|
marker.click();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (clicked) return true;
|
|
|
|
const createSelectors = [
|
|
'a[href*="/compose"]',
|
|
'a[href*="intent/post"]',
|
|
'[aria-label="建立"]',
|
|
'[aria-label="Create"]',
|
|
'div[role="button"]:has-text("建立")',
|
|
'div[role="button"]:has-text("Create")',
|
|
];
|
|
|
|
for (const selector of createSelectors) {
|
|
const target = page.locator(selector).first();
|
|
if (await target.isVisible({ timeout: 1200 }).catch(() => false)) {
|
|
await target.click({ force: true });
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export async function findComposeEditor(
|
|
page: Page,
|
|
debug?: DebugRun | null,
|
|
timeoutMs = 18000
|
|
): Promise<Locator | null> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
const editor = await locateComposeEditor(page);
|
|
if (editor) {
|
|
await captureDebugStep(page, debug, "editor-found");
|
|
return editor;
|
|
}
|
|
await page.waitForTimeout(600);
|
|
}
|
|
|
|
await captureDebugStep(page, debug, "editor-not-found", {
|
|
textboxCount: await page.getByRole("textbox").count(),
|
|
contentEditableCount: await page.locator('[contenteditable="true"]').count(),
|
|
dialogCount: await page.locator('[role="dialog"]').count(),
|
|
url: page.url(),
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
async function locateComposeEditor(page: Page): Promise<Locator | null> {
|
|
const selectors = [
|
|
'[role="dialog"] [contenteditable="true"][role="textbox"]',
|
|
'[role="dialog"] [contenteditable="true"]',
|
|
'[contenteditable="true"][role="textbox"]',
|
|
'[contenteditable="true"][aria-label*="文字"]',
|
|
'[contenteditable="true"][aria-label*="text"]',
|
|
'[contenteditable="true"][aria-label*="串文"]',
|
|
'[contenteditable="true"][aria-label*="thread"]',
|
|
'[data-lexical-editor="true"]',
|
|
'div[role="textbox"]',
|
|
'[contenteditable="true"]',
|
|
"textarea",
|
|
];
|
|
|
|
for (const selector of selectors) {
|
|
const locator = page.locator(selector);
|
|
const count = await locator.count();
|
|
for (let i = 0; i < count; i++) {
|
|
const candidate = locator.nth(i);
|
|
if (await isUsableEditor(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const pattern of COMPOSE_PLACEHOLDERS) {
|
|
const byPlaceholder = page.getByPlaceholder(pattern).first();
|
|
if (await byPlaceholder.isVisible({ timeout: 400 }).catch(() => false)) {
|
|
return byPlaceholder;
|
|
}
|
|
}
|
|
|
|
const textboxes = await page.getByRole("textbox").all();
|
|
for (const textbox of textboxes) {
|
|
if (!(await textbox.isVisible().catch(() => false))) continue;
|
|
const editable = await textbox.getAttribute("contenteditable");
|
|
const aria = (await textbox.getAttribute("aria-label")) ?? "";
|
|
const placeholder = (await textbox.getAttribute("placeholder")) ?? "";
|
|
if (
|
|
editable === "true" ||
|
|
/文字|text|串文|貼文|compose|thread/i.test(aria + placeholder)
|
|
) {
|
|
return textbox;
|
|
}
|
|
}
|
|
|
|
const found = await page.evaluate(() => {
|
|
const nodes = Array.from(document.querySelectorAll('[contenteditable="true"], [role="textbox"]'));
|
|
for (const node of nodes) {
|
|
if (!(node instanceof HTMLElement)) continue;
|
|
const rect = node.getBoundingClientRect();
|
|
if (rect.width < 80 || rect.height < 20) continue;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.visibility === "hidden" || style.display === "none") continue;
|
|
node.setAttribute("data-threadtools-editor", "true");
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (found) {
|
|
const marked = page.locator('[data-threadtools-editor="true"]').first();
|
|
if (await marked.count()) return marked;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function isUsableEditor(candidate: Locator): Promise<boolean> {
|
|
if (!(await candidate.isVisible({ timeout: 400 }).catch(() => false))) return false;
|
|
const box = await candidate.boundingBox().catch(() => null);
|
|
if (!box || box.width < 60 || box.height < 16) return false;
|
|
return true;
|
|
}
|
|
|
|
export async function probeComposeEditor(page: Page, debug?: DebugRun | null): Promise<ComposeReadyResult> {
|
|
const result = await openComposeSurface(page, debug);
|
|
if (!result.ready) return result;
|
|
|
|
const editor = await findComposeEditor(page, debug, 8000);
|
|
if (!editor) {
|
|
return {
|
|
ready: false,
|
|
reason: "editor_not_found",
|
|
message: composeFailureMessage("editor_not_found"),
|
|
};
|
|
}
|
|
|
|
await dismissBlockingDialogs(page, debug);
|
|
return { ready: true };
|
|
}
|
|
|
|
export function composeFailureMessage(reason?: ComposeBlockReason): string {
|
|
switch (reason) {
|
|
case "instagram_reauth":
|
|
return "Threads 需要完成 Instagram 授權。請到設定頁重新登入,並在彈出的瀏覽器完成 Instagram 登入。";
|
|
case "login_redirect":
|
|
return "Session 已失效,請到設定頁重新登入";
|
|
case "onboarding_dialog":
|
|
return "Threads 有未完成設定的彈窗擋住發文,請重新登入並完成設定";
|
|
case "editor_not_found":
|
|
default:
|
|
return "找不到撰寫欄位。請開啟 Debug 模式查看瀏覽器,或到設定頁重新登入並完成 Instagram 授權。";
|
|
}
|
|
} |