|
|
@@ -126,6 +126,22 @@ export interface CookieData {
|
|
|
path: string;
|
|
|
}
|
|
|
|
|
|
+export type CookieCheckSource = 'python' | 'api' | 'browser';
|
|
|
+export type CookieCheckReason =
|
|
|
+ | 'valid'
|
|
|
+ | 'need_login'
|
|
|
+ | 'risk_control'
|
|
|
+ | 'uncertain';
|
|
|
+
|
|
|
+export interface CookieCheckResult {
|
|
|
+ isValid: boolean;
|
|
|
+ needReLogin: boolean;
|
|
|
+ uncertain: boolean;
|
|
|
+ reason: CookieCheckReason;
|
|
|
+ source: CookieCheckSource;
|
|
|
+ message?: string;
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* 无头浏览器服务 - 用于后台静默获取账号信息
|
|
|
*/
|
|
|
@@ -135,40 +151,86 @@ class HeadlessBrowserService {
|
|
|
* 优先使用 Python 服务(通过浏览器访问后台检测),回退到 API 检查
|
|
|
*/
|
|
|
async checkCookieValid(platform: PlatformType, cookies: CookieData[]): Promise<boolean> {
|
|
|
- logger.info(`[checkCookieValid] Checking cookie for ${platform}, cookie count: ${cookies.length}`);
|
|
|
+ const status = await this.checkCookieStatus(platform, cookies);
|
|
|
+ return status.isValid || status.uncertain;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查 Cookie 状态(有效 / 需要重新登录 / 不确定)
|
|
|
+ */
|
|
|
+ async checkCookieStatus(platform: PlatformType, cookies: CookieData[]): Promise<CookieCheckResult> {
|
|
|
+ logger.info(`[checkCookieStatus] Checking cookie for ${platform}, cookie count: ${cookies.length}`);
|
|
|
|
|
|
- // 优先使用 Python 服务检查(通过浏览器访问后台页面,检测是否被重定向到登录页)
|
|
|
const pythonAvailable = await this.checkPythonServiceAvailable();
|
|
|
if (pythonAvailable) {
|
|
|
try {
|
|
|
- const result = await this.checkLoginViaPython(platform, cookies);
|
|
|
- logger.info(`[checkCookieValid] Python service result for ${platform}: ${result}`);
|
|
|
+ const result = await this.checkLoginStatusViaPython(platform, cookies);
|
|
|
+ logger.info(
|
|
|
+ `[checkCookieStatus] Python result for ${platform}: isValid=${result.isValid}, needReLogin=${result.needReLogin}, uncertain=${result.uncertain}, reason=${result.reason}`
|
|
|
+ );
|
|
|
return result;
|
|
|
} catch (error) {
|
|
|
- logger.warn(`[Python API] Check login failed, falling back to API check:`, error);
|
|
|
+ logger.warn(`[Python API] Check login failed, falling back:`, error);
|
|
|
}
|
|
|
} else {
|
|
|
- logger.info(`[checkCookieValid] Python service not available, using API check`);
|
|
|
+ logger.info(`[checkCookieStatus] Python service not available`);
|
|
|
}
|
|
|
|
|
|
- // 回退到 API 检查
|
|
|
const apiConfig = PLATFORM_API_CONFIG[platform];
|
|
|
if (apiConfig) {
|
|
|
- const result = await this.checkCookieValidByApi(platform, cookies, apiConfig);
|
|
|
- logger.info(`[checkCookieValid] API check result for ${platform}: ${result}`);
|
|
|
+ const result = await this.checkCookieStatusByApi(platform, cookies, apiConfig);
|
|
|
+ logger.info(
|
|
|
+ `[checkCookieStatus] API result for ${platform}: isValid=${result.isValid}, needReLogin=${result.needReLogin}, uncertain=${result.uncertain}, reason=${result.reason}`
|
|
|
+ );
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
- // 其他平台使用浏览器检查
|
|
|
- const result = await this.checkCookieValidByBrowser(platform, cookies);
|
|
|
- logger.info(`[checkCookieValid] Browser check result for ${platform}: ${result}`);
|
|
|
+ const result = await this.checkCookieStatusByBrowser(platform, cookies);
|
|
|
+ logger.info(
|
|
|
+ `[checkCookieStatus] Browser result for ${platform}: isValid=${result.isValid}, needReLogin=${result.needReLogin}, uncertain=${result.uncertain}, reason=${result.reason}`
|
|
|
+ );
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
+ private containsRiskKeywords(text: string): boolean {
|
|
|
+ if (!text) return false;
|
|
|
+ const lowered = text.toLowerCase();
|
|
|
+ const keywords = [
|
|
|
+ '验证码',
|
|
|
+ '安全验证',
|
|
|
+ '人机验证',
|
|
|
+ '滑块',
|
|
|
+ '风控',
|
|
|
+ '风险',
|
|
|
+ '访问受限',
|
|
|
+ '行为异常',
|
|
|
+ '系统检测到异常',
|
|
|
+ '安全校验',
|
|
|
+ 'captcha',
|
|
|
+ 'verify',
|
|
|
+ 'challenge',
|
|
|
+ 'risk',
|
|
|
+ 'security',
|
|
|
+ 'safe',
|
|
|
+ 'protect',
|
|
|
+ 'blocked',
|
|
|
+ ];
|
|
|
+ return keywords.some(k => lowered.includes(k.toLowerCase()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private async getPageBodyTextSafe(page: Page): Promise<string> {
|
|
|
+ try {
|
|
|
+ const content = await page.textContent('body');
|
|
|
+ return (content || '').slice(0, 8000);
|
|
|
+ } catch {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 通过 Python 服务检查登录状态(浏览器访问后台页面,检测是否需要登录)
|
|
|
*/
|
|
|
- private async checkLoginViaPython(platform: PlatformType, cookies: CookieData[]): Promise<boolean> {
|
|
|
+ private async checkLoginStatusViaPython(platform: PlatformType, cookies: CookieData[]): Promise<CookieCheckResult> {
|
|
|
const pythonUrl = PYTHON_SERVICE_URL;
|
|
|
|
|
|
// 构建 Cookie 字符串
|
|
|
@@ -203,18 +265,48 @@ class HeadlessBrowserService {
|
|
|
throw new Error(result.error || 'Check login failed');
|
|
|
}
|
|
|
|
|
|
- return result.valid && !result.need_login;
|
|
|
+ const message = result.message || result.error || '';
|
|
|
+ if (!result.valid || result.need_login) {
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: true,
|
|
|
+ uncertain: false,
|
|
|
+ reason: this.containsRiskKeywords(message) ? 'risk_control' : 'need_login',
|
|
|
+ source: 'python',
|
|
|
+ message,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.containsRiskKeywords(message)) {
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: true,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'risk_control',
|
|
|
+ source: 'python',
|
|
|
+ message,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ isValid: true,
|
|
|
+ needReLogin: false,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'valid',
|
|
|
+ source: 'python',
|
|
|
+ message,
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 通过 API 检查 Cookie 是否有效
|
|
|
* 注意:API 返回不确定状态时会回退到浏览器检查,避免误判
|
|
|
*/
|
|
|
- private async checkCookieValidByApi(
|
|
|
+ private async checkCookieStatusByApi(
|
|
|
platform: PlatformType,
|
|
|
cookies: CookieData[],
|
|
|
apiConfig: typeof PLATFORM_API_CONFIG[string]
|
|
|
- ): Promise<boolean> {
|
|
|
+ ): Promise<CookieCheckResult> {
|
|
|
try {
|
|
|
// 构建 Cookie 字符串(所有 Cookie 拼接)
|
|
|
const cookieString = cookies
|
|
|
@@ -255,7 +347,24 @@ class HeadlessBrowserService {
|
|
|
|
|
|
// 如果 API 明确返回有效,直接返回 true
|
|
|
if (isValid) {
|
|
|
- return true;
|
|
|
+ const rawText = JSON.stringify(data).slice(0, 2000);
|
|
|
+ if (this.containsRiskKeywords(rawText)) {
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: true,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'risk_control',
|
|
|
+ source: 'api',
|
|
|
+ message: 'API 返回疑似风控/验证页面',
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ isValid: true,
|
|
|
+ needReLogin: false,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'valid',
|
|
|
+ source: 'api',
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
// API 返回无效时,检查是否是明确的"未登录"状态
|
|
|
@@ -265,7 +374,13 @@ class HeadlessBrowserService {
|
|
|
|
|
|
if (clearlyNotLoggedIn) {
|
|
|
logger.info(`[API] Platform ${platform} clearly not logged in (statusCode=${statusCode})`);
|
|
|
- return false;
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: true,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'need_login',
|
|
|
+ source: 'api',
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
// 百家号特殊处理:API 用 Node fetch 调用时可能因分散认证等返回 errno !== 0,但 Cookie 在浏览器内仍有效
|
|
|
@@ -273,28 +388,31 @@ class HeadlessBrowserService {
|
|
|
if (platform === 'baijiahao') {
|
|
|
const errno = (data as { errno?: number })?.errno;
|
|
|
|
|
|
- if (errno === 0 && isValid) return true;
|
|
|
+ if (errno === 0 && isValid) {
|
|
|
+ return { isValid: true, needReLogin: false, uncertain: false, reason: 'valid', source: 'api' };
|
|
|
+ }
|
|
|
+
|
|
|
if (errno === 0 && !isValid) {
|
|
|
logger.warn(`[API] Baijiahao errno=0 but no user info, falling back to browser check`);
|
|
|
- return this.checkCookieValidByBrowser(platform, cookies);
|
|
|
+ return this.checkCookieStatusByBrowser(platform, cookies);
|
|
|
}
|
|
|
|
|
|
// errno 110 通常表示未登录,可直接判无效;其他 errno(如 10001402 分散认证)可能只是接口限制,用浏览器再判一次
|
|
|
if (errno === 110) {
|
|
|
logger.warn(`[API] Baijiahao errno=110 (not logged in), cookie invalid`);
|
|
|
- return false;
|
|
|
+ return { isValid: false, needReLogin: true, uncertain: false, reason: 'need_login', source: 'api' };
|
|
|
}
|
|
|
logger.info(`[API] Baijiahao errno=${errno}, falling back to browser check (may be dispersed auth)`);
|
|
|
- return this.checkCookieValidByBrowser(platform, cookies);
|
|
|
+ return this.checkCookieStatusByBrowser(platform, cookies);
|
|
|
}
|
|
|
|
|
|
// 不确定的状态(如 status_code=7),回退到浏览器检查
|
|
|
logger.info(`[API] Uncertain status for ${platform} (statusCode=${statusCode}), falling back to browser check`);
|
|
|
- return this.checkCookieValidByBrowser(platform, cookies);
|
|
|
+ return this.checkCookieStatusByBrowser(platform, cookies);
|
|
|
} catch (error) {
|
|
|
logger.error(`API check cookie error for ${platform}:`, error);
|
|
|
// API 检查失败时,回退到浏览器检查
|
|
|
- return this.checkCookieValidByBrowser(platform, cookies);
|
|
|
+ return this.checkCookieStatusByBrowser(platform, cookies);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -315,10 +433,10 @@ class HeadlessBrowserService {
|
|
|
* 通过浏览器检查 Cookie 是否有效(检查是否被重定向到登录页)
|
|
|
* 注意:网络错误或服务不可用时返回 true(保持原状态),避免误判为过期
|
|
|
*/
|
|
|
- private async checkCookieValidByBrowser(platform: PlatformType, cookies: CookieData[]): Promise<boolean> {
|
|
|
+ private async checkCookieStatusByBrowser(platform: PlatformType, cookies: CookieData[]): Promise<CookieCheckResult> {
|
|
|
// 对于抖音平台,使用 check/user 接口检查
|
|
|
if (platform === 'douyin') {
|
|
|
- return this.checkDouyinLoginByApi(cookies);
|
|
|
+ return this.checkDouyinLoginStatusByApi(cookies);
|
|
|
}
|
|
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
@@ -349,18 +467,52 @@ class HeadlessBrowserService {
|
|
|
|
|
|
// 检查是否被重定向到登录页
|
|
|
const isLoginPage = config.loginIndicators.some(indicator => url.includes(indicator));
|
|
|
+ const bodyText = await this.getPageBodyTextSafe(page);
|
|
|
+ const isRiskControl = this.containsRiskKeywords(url) || this.containsRiskKeywords(bodyText);
|
|
|
|
|
|
await page.close();
|
|
|
await context.close();
|
|
|
await browser.close();
|
|
|
|
|
|
- return !isLoginPage;
|
|
|
+ if (isLoginPage) {
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: true,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'need_login',
|
|
|
+ source: 'browser',
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isRiskControl) {
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: true,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'risk_control',
|
|
|
+ source: 'browser',
|
|
|
+ message: '检测到风控/验证页面',
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ isValid: true,
|
|
|
+ needReLogin: false,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'valid',
|
|
|
+ source: 'browser',
|
|
|
+ };
|
|
|
} catch (error) {
|
|
|
logger.error(`Browser check cookie error for ${platform}:`, error);
|
|
|
await browser.close();
|
|
|
- // 网络错误或服务不可用时返回 true,避免误判为过期
|
|
|
- logger.warn(`[Browser] Check failed due to error, assuming valid to avoid false expiration`);
|
|
|
- return true;
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: false,
|
|
|
+ uncertain: true,
|
|
|
+ reason: 'uncertain',
|
|
|
+ source: 'browser',
|
|
|
+ message: error instanceof Error ? error.message : 'Browser check error',
|
|
|
+ };
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -368,10 +520,11 @@ class HeadlessBrowserService {
|
|
|
* 抖音登录状态检查 - 通过监听 check/user 接口
|
|
|
* 访问创作者首页,监听 check/user 接口返回的 result 字段判断登录状态
|
|
|
*/
|
|
|
- async checkDouyinLoginByApi(cookies: CookieData[]): Promise<boolean> {
|
|
|
+ private async checkDouyinLoginStatusByApi(cookies: CookieData[]): Promise<CookieCheckResult> {
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
let isLoggedIn = false;
|
|
|
let checkCompleted = false;
|
|
|
+ let isRiskControl = false;
|
|
|
|
|
|
try {
|
|
|
const context = await browser.newContext({
|
|
|
@@ -419,19 +572,45 @@ class HeadlessBrowserService {
|
|
|
logger.info(`[Douyin] No check/user response, fallback to URL check: ${currentUrl}, isLoggedIn=${isLoggedIn}`);
|
|
|
}
|
|
|
|
|
|
+ const finalUrl = page.url();
|
|
|
+ const bodyText = await this.getPageBodyTextSafe(page);
|
|
|
+ isRiskControl = this.containsRiskKeywords(finalUrl) || this.containsRiskKeywords(bodyText);
|
|
|
+
|
|
|
await page.close();
|
|
|
await context.close();
|
|
|
await browser.close();
|
|
|
|
|
|
- return isLoggedIn;
|
|
|
+ if (isRiskControl) {
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: true,
|
|
|
+ uncertain: false,
|
|
|
+ reason: 'risk_control',
|
|
|
+ source: 'browser',
|
|
|
+ message: '检测到风控/验证页面',
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ isValid: isLoggedIn,
|
|
|
+ needReLogin: !isLoggedIn,
|
|
|
+ uncertain: false,
|
|
|
+ reason: isLoggedIn ? 'valid' : 'need_login',
|
|
|
+ source: 'browser',
|
|
|
+ };
|
|
|
} catch (error) {
|
|
|
logger.error('[Douyin] checkDouyinLoginByApi error:', error);
|
|
|
try {
|
|
|
await browser.close();
|
|
|
} catch { }
|
|
|
- // 网络错误或服务不可用时返回 true,避免误判为过期
|
|
|
- logger.warn(`[Douyin] Check failed due to error, assuming valid to avoid false expiration`);
|
|
|
- return true;
|
|
|
+ return {
|
|
|
+ isValid: false,
|
|
|
+ needReLogin: false,
|
|
|
+ uncertain: true,
|
|
|
+ reason: 'uncertain',
|
|
|
+ source: 'browser',
|
|
|
+ message: error instanceof Error ? error.message : 'Douyin check error',
|
|
|
+ };
|
|
|
}
|
|
|
}
|
|
|
|