haixunMaster/lib/threads-browser/attach-media.ts

272 lines
7.7 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
import { execFile } from "child_process";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import path from "path";
import { promisify } from "util";
import type { Page } from "playwright";
import { humanDelay } from "@/lib/utils";
import { draftImageAbsolutePath } from "@/lib/drafts/images";
import type { DebugRun } from "./debug";
import { captureDebugStep } from "./debug";
const execFileAsync = promisify(execFile);
const ATTACH_TRIGGER_SELECTORS = [
'[role="dialog"] [aria-label*="附加"]',
'[role="dialog"] [aria-label*="Attach"]',
'[role="dialog"] [aria-label*="媒體"]',
'[role="dialog"] [aria-label*="Media"]',
'[role="dialog"] [aria-label*="照片"]',
'[role="dialog"] [aria-label*="Photo"]',
'[role="dialog"] [aria-label*="相簿"]',
'[role="dialog"] [aria-label*="Album"]',
'[role="dialog"] [aria-label*="圖片"]',
'[role="dialog"] [aria-label*="Image"]',
'[aria-label*="附加"]',
'[aria-label*="Attach"]',
'[aria-label*="媒體"]',
'[aria-label*="Media"]',
'[aria-label*="照片"]',
'[aria-label*="Photo"]',
'[aria-label*="相簿"]',
'[aria-label*="Album"]',
'[aria-label*="圖片"]',
'[aria-label*="Image"]',
'[aria-label*="從電腦"]',
'[aria-label*="Upload"]',
'div[role="button"]:has-text("附加")',
'div[role="button"]:has-text("Attach")',
];
const IMAGE_PREVIEW_SELECTORS = [
'[role="dialog"] img[src^="blob:"]',
'[role="dialog"] img[src^="data:"]',
'[role="dialog"] video[src^="blob:"]',
'[role="dialog"] [aria-label*="移除"]',
'[role="dialog"] [aria-label*="Remove"]',
'[role="dialog"] [aria-label*="刪除"]',
'[role="dialog"] [aria-label*="Delete"]',
'[role="dialog"] [data-testid*="media"]',
];
export interface PreparedUploadImage {
path: string;
cleanup?: () => Promise<void>;
}
export async function prepareImageForBrowserUpload(imagePath: string): Promise<PreparedUploadImage> {
const absolute = draftImageAbsolutePath(imagePath);
const ext = path.extname(imagePath).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg" || ext === ".png") {
return { path: absolute };
}
if (process.platform === "darwin" && (ext === ".webp" || ext === ".gif")) {
const tempDir = await mkdtemp(path.join(tmpdir(), "threadtools-img-"));
const outPath = path.join(tempDir, "upload.jpg");
try {
await execFileAsync("sips", ["-s", "format", "jpeg", absolute, "--out", outPath]);
return {
path: outPath,
cleanup: async () => {
await rm(tempDir, { recursive: true, force: true });
},
};
} catch {
await rm(tempDir, { recursive: true, force: true });
}
}
return { path: absolute };
}
async function dismissCropOrConfirmDialog(page: Page) {
const labels = [/完成/, /Done/, /加入/, /Add/, /套用/, /Apply/, /確定/, /OK/];
for (const pattern of labels) {
const btn = page.getByRole("button", { name: pattern }).first();
if (await btn.isVisible({ timeout: 400 }).catch(() => false)) {
await btn.click().catch(() => undefined);
await humanDelay(500, 900);
}
}
}
async function waitForImagePreview(page: Page, debug?: DebugRun | null, timeoutMs = 20000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await dismissCropOrConfirmDialog(page);
for (const selector of IMAGE_PREVIEW_SELECTORS) {
if (await page.locator(selector).first().isVisible({ timeout: 400 }).catch(() => false)) {
await captureDebugStep(page, debug, "attach-image-preview");
return true;
}
}
await page.waitForTimeout(500);
}
return false;
}
async function uploadViaFileChooser(
page: Page,
imagePath: string,
debug?: DebugRun | null
): Promise<boolean> {
for (const selector of ATTACH_TRIGGER_SELECTORS) {
const trigger = page.locator(selector).first();
if (!(await trigger.isVisible({ timeout: 900 }).catch(() => false))) continue;
try {
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: 6000 }),
trigger.click(),
]);
await fileChooser.setFiles(imagePath);
await humanDelay(800, 1400);
if (await waitForImagePreview(page, debug)) return true;
} catch {
// try next trigger
}
}
return false;
}
async function uploadViaHiddenInput(
page: Page,
imagePath: string,
debug?: DebugRun | null
): Promise<boolean> {
const inputs = page.locator('input[type="file"]');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
try {
await input.setInputFiles(imagePath);
await humanDelay(800, 1400);
if (await waitForImagePreview(page, debug)) return true;
} catch {
// try next input
}
}
return false;
}
async function uploadViaToolbarEvaluate(
page: Page,
imagePath: string,
debug?: DebugRun | null
): Promise<boolean> {
const clicked = await page.evaluate(() => {
const labels = ["附加", "Attach", "媒體", "Media", "照片", "Photo", "相簿", "Album"];
for (const label of labels) {
const node = document.querySelector(`[aria-label*="${label}"]`);
if (node instanceof HTMLElement) {
node.click();
return true;
}
}
return false;
});
if (!clicked) return false;
try {
const fileChooser = await page.waitForEvent("filechooser", { timeout: 5000 });
await fileChooser.setFiles(imagePath);
await humanDelay(800, 1400);
return waitForImagePreview(page, debug);
} catch {
return uploadViaHiddenInput(page, imagePath, debug);
}
}
async function attachMultipleImages(
page: Page,
imagePaths: string[],
debug?: DebugRun | null
): Promise<boolean> {
if (imagePaths.length <= 1) return false;
for (const selector of ATTACH_TRIGGER_SELECTORS) {
const trigger = page.locator(selector).first();
if (!(await trigger.isVisible({ timeout: 900 }).catch(() => false))) continue;
try {
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: 6000 }),
trigger.click(),
]);
await fileChooser.setFiles(imagePaths);
await humanDelay(1200, 2000);
if (await waitForImagePreview(page, debug, 25000)) {
await captureDebugStep(page, debug, "attach-images-multi", { count: imagePaths.length });
return true;
}
} catch {
// try next trigger
}
}
return false;
}
export async function attachImages(
page: Page,
imagePaths: string[],
debug?: DebugRun | null
): Promise<boolean> {
if (imagePaths.length === 0) return true;
const prepared = await Promise.all(imagePaths.map((p) => prepareImageForBrowserUpload(p)));
try {
const paths = prepared.map((item) => item.path);
if (paths.length > 1 && (await attachMultipleImages(page, paths, debug))) {
return true;
}
for (const item of prepared) {
const attached = await attachImage(page, item.path, debug);
if (!attached) return false;
await humanDelay(900, 1400);
}
return true;
} finally {
await Promise.all(prepared.map((item) => item.cleanup?.()));
}
}
export async function attachImage(
page: Page,
imagePath: string,
debug?: DebugRun | null
): Promise<boolean> {
await captureDebugStep(page, debug, "attach-image-start", { imagePath });
if (await uploadViaFileChooser(page, imagePath, debug)) {
await captureDebugStep(page, debug, "attach-image-done");
return true;
}
if (await uploadViaHiddenInput(page, imagePath, debug)) {
await captureDebugStep(page, debug, "attach-image-done");
return true;
}
if (await uploadViaToolbarEvaluate(page, imagePath, debug)) {
await captureDebugStep(page, debug, "attach-image-done");
return true;
}
await captureDebugStep(page, debug, "attach-image-failed");
return false;
}