haixunMaster/lib/threads-browser/session.ts

355 lines
11 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 { 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 }
);
}