haixunMaster/lib/threads-browser/session.ts

355 lines
11 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
import { getActiveAccountProfile, setActiveAccountForUser } from "@/lib/account-context";
import { prisma } from "@/lib/db";
import { requireSessionUser } from "@/lib/auth/session";
import { humanDelay } from "@/lib/utils";
import { assessComposeReady, dismissBlockingDialogs } from "./compose";
import {
clearBrowserProfile,
launchLoginContext,
launchSessionContext,
withPage,
} from "./browser";
import type { BrowserContext } from "playwright";
import { withSessionLock } from "./session-lock";
const LOGIN_URL = "https://www.threads.com/login";
const HOME_URL = "https://www.threads.com/";
export class SessionError extends Error {
constructor(message: string) {
super(message);
this.name = "SessionError";
}
}
export interface ActiveSession {
id: string;
username: string | null;
storageState: string;
}
export async function saveAccountSession(
accountId: string,
storageState: string,
options?: { valid?: boolean; username?: string | null }
) {
await prisma.account.update({
where: { id: accountId },
data: {
storageState,
...(options?.valid !== undefined && { valid: options.valid }),
...(options?.username !== undefined && { username: options.username }),
},
});
}
async function persistStorageState(accountId: string, storageState: string, valid = true) {
await saveAccountSession(accountId, storageState, { valid });
}
export async function clearBrowserSession(): Promise<{ cleared: boolean; message: string }> {
const account = await getActiveAccount();
const profileResult = await withSessionLock(() => clearBrowserProfile());
if (account) {
await saveAccountSession(account.id, "", { valid: false });
return {
cleared: true,
message: "已清除此帳號的瀏覽器 session",
};
}
return profileResult;
}
export async function startLoginFlow(options?: {
clearSession?: boolean;
}): Promise<{ success: boolean; message: string }> {
return withSessionLock(async () => {
if (options?.clearSession !== false) {
await clearBrowserProfile();
}
const context = await launchLoginContext();
const page = context.pages()[0] ?? (await context.newPage());
try {
await page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 60000 });
try {
await page.waitForURL(
(url) =>
url.hostname.includes("threads") &&
!url.pathname.includes("/login") &&
!url.hostname.includes("instagram"),
{ timeout: 300000 }
);
} catch {
// 可能停在 Instagram 授權頁,繼續等待使用者完成
}
const deadline = Date.now() + 300000;
let compose = await assessComposeReady(page);
while (!compose.ready && Date.now() < deadline) {
if (page.url().includes("instagram.com")) {
await page.waitForTimeout(2000);
} else {
await page.goto(HOME_URL, { waitUntil: "domcontentloaded", timeout: 45000 }).catch(() => undefined);
await humanDelay(1500, 2500);
await dismissBlockingDialogs(page);
compose = await assessComposeReady(page);
}
if (!compose.ready) await page.waitForTimeout(2000);
}
if (!compose.ready) {
return {
success: false,
message:
compose.message ??
"登入逾時。請確認已在瀏覽器完成 Instagram 授權,並看見 Threads 首頁後再試。",
};
}
await dismissBlockingDialogs(page);
compose = await assessComposeReady(page);
if (!compose.ready) {
return {
success: false,
message:
compose.message ??
"登入未完成,請在瀏覽器內完成 Instagram 授權後再試一次",
};
}
const storageState = JSON.stringify(await context.storageState());
const username = await extractUsername(page);
const user = await requireSessionUser();
const active = await getActiveAccountProfile();
const existing =
active ??
(username
? await prisma.account.findFirst({ where: { userId: user.id, username } })
: null);
const account = existing
? await prisma.account.update({
where: { id: existing.id },
data: {
username,
displayName: existing.displayName ?? username ?? undefined,
storageState,
valid: true,
},
})
: await prisma.account.create({
data: {
userId: user.id,
username,
displayName: username ?? "Threads 帳號",
storageState,
valid: true,
},
});
await setActiveAccountForUser(user.id, account.id);
await saveAccountSession(account.id, storageState, { valid: true, username });
return { success: true, message: username ? `已登入:@${username}` : "登入成功" };
} finally {
await context.close();
}
});
}
async function extractUsername(page: import("playwright").Page): Promise<string | null> {
try {
const profileLink = page.locator('a[href*="/@"]').first();
const href = await profileLink.getAttribute("href", { timeout: 5000 });
if (!href) return null;
const match = href.match(/@([^/?]+)/);
return match?.[1] ?? null;
} catch {
return null;
}
}
export async function getActiveAccount() {
return getActiveAccountProfile();
}
export async function refreshSession(account?: {
id: string;
username: string | null;
storageState: string;
}): Promise<{
valid: boolean;
username: string | null;
storageState: string;
message: string;
refreshed: boolean;
}> {
const current = account ?? (await getActiveAccount());
if (!current) {
return {
valid: false,
username: null,
storageState: "",
message: "尚未登入 Threads",
refreshed: false,
};
}
return withSessionLock(async () => {
let context: BrowserContext;
try {
context = await launchSessionContext({ storageState: current.storageState });
} catch (error) {
await saveAccountSession(current.id, current.storageState, { valid: false });
return {
valid: false,
username: current.username,
storageState: current.storageState,
refreshed: false,
message: error instanceof Error ? error.message : "無法啟動瀏覽器,請稍後再試",
};
}
try {
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(HOME_URL, { waitUntil: "domcontentloaded", timeout: 45000 });
await humanDelay(1200, 2200);
await dismissBlockingDialogs(page);
const compose = await assessComposeReady(page);
const valid = compose.ready;
const username = valid ? (await extractUsername(page)) ?? current.username : current.username;
const storageState = JSON.stringify(await context.storageState());
await saveAccountSession(current.id, storageState, { valid, username });
return {
valid,
username,
storageState,
refreshed: true,
message: valid
? "Session 已自動更新,可正常發文"
: (compose.message ?? "Session 已失效,請重新登入"),
};
} catch (error) {
await saveAccountSession(current.id, current.storageState, { valid: false });
const message = error instanceof Error ? error.message : "更新 session 失敗";
return {
valid: false,
username: current.username,
storageState: current.storageState,
refreshed: false,
message,
};
} finally {
await context.close();
}
});
}
export async function ensureActiveSession(): Promise<ActiveSession> {
const account = await getActiveAccount();
if (!account) {
throw new SessionError("尚未登入 Threads請到設定頁登入");
}
if (!account.storageState) {
throw new SessionError("目前經營帳號尚未登入 Threads請到設定頁登入此帳號");
}
const refreshed = await refreshSession(account);
if (!refreshed.valid) {
throw new SessionError(refreshed.message || "Threads session 已失效,請到設定頁重新登入");
}
return {
id: account.id,
username: refreshed.username,
storageState: refreshed.storageState,
};
}
export function browserSessionOptions(session: ActiveSession) {
return {
accountId: session.id,
storageState: session.storageState,
onSessionPersisted: (storageState: string) => persistStorageState(session.id, storageState),
};
}
export function accountHasStoredSession(account: { storageState: string } | null): boolean {
return !!account?.storageState && account.storageState.length > 2;
}
/** 讀取 DB 已儲存的同步狀態,不啟動瀏覽器驗證(避免每次進頁面都把「已同步」洗成未同步)。 */
export async function getStoredSessionStatus(): Promise<{
synced: boolean;
valid: boolean;
username?: string | null;
message: string;
}> {
const account = await getActiveAccount();
if (!account) {
return { synced: false, valid: false, message: "尚未選擇經營帳號" };
}
if (!accountHasStoredSession(account)) {
return {
synced: false,
valid: false,
username: account.username,
message: "尚未同步 — 請在 Chrome 登入 threads.com 後按「從 Chrome 同步」",
};
}
return {
synced: true,
valid: true,
username: account.username,
message: account.username
? `已同步 · @${account.username}`
: "已同步到 serversession 已儲存)",
};
}
/** 啟動瀏覽器實際驗證並更新 session用於發文/海巡前,不作為 UI 狀態來源)。 */
export async function checkSessionValid(): Promise<{
valid: boolean;
username?: string | null;
message: string;
refreshed?: boolean;
}> {
const account = await getActiveAccount();
if (!account) {
return { valid: false, message: "尚未登入 Threads" };
}
if (!accountHasStoredSession(account)) {
return { valid: false, username: account.username, message: "目前經營帳號尚未同步 Threads session" };
}
const result = await refreshSession(account);
return {
valid: result.valid,
username: result.username,
message: result.message,
refreshed: result.refreshed,
};
}
export async function probeSession(storageState: string): Promise<boolean> {
return withPage(
storageState,
async (page) => {
await page.goto(HOME_URL, { waitUntil: "domcontentloaded", timeout: 30000 });
await humanDelay(1200, 2000);
return !page.url().includes("/login");
},
{ persistSession: false }
);
}