272 lines
7.7 KiB
TypeScript
272 lines
7.7 KiB
TypeScript
|
|
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;
|
||
|
|
}
|