279 lines
11 KiB
JavaScript
279 lines
11 KiB
JavaScript
import { getBrowserContext } from './browser.mjs';
|
||
import { sleep } from './utils.mjs';
|
||
|
||
const HOME_URL = 'https://www.goofish.com/';
|
||
const SEARCH_PROBE_URL = 'https://www.goofish.com/search?q=e3%201245%20v3';
|
||
const QR_LOGIN_URL = 'https://www.goofish.com/';
|
||
|
||
function accountTextValue(bodyText, labels) {
|
||
const lines = String(bodyText || '').split(/\n+/).map(s => s.trim()).filter(Boolean);
|
||
for (const label of labels) {
|
||
const idx = lines.findIndex(line => line === label || line.startsWith(`${label}:`) || line.startsWith(`${label}:`));
|
||
if (idx >= 0) {
|
||
const inline = lines[idx].replace(new RegExp(`^${label}[::]?\\s*`), '').trim();
|
||
if (inline && inline !== label) return inline;
|
||
if (lines[idx + 1]) return lines[idx + 1];
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async function extractAccountInfo(page, bodyText) {
|
||
const title = await page.title().catch(() => '');
|
||
const nickTexts = await page.locator('[class*="nick--"], [class*="user-name"], [class*="nickname"], [class*="userName"]').evaluateAll(nodes => (
|
||
nodes.map(n => n.textContent?.trim()).filter(Boolean).slice(0, 5)
|
||
)).catch(() => []);
|
||
const avatarUrl = await page.locator('[class*="user-order-container"] img, img[class*="avatar"], [class*="avatar"] img').first().getAttribute('src', { timeout: 1200 }).catch(() => '');
|
||
const accountName = nickTexts[0] || accountTextValue(bodyText, ['昵称', '用户名', '账号']) || '';
|
||
return {
|
||
accountName,
|
||
nickCandidates: nickTexts,
|
||
avatarUrl: avatarUrl || '',
|
||
pageTitle: title,
|
||
};
|
||
}
|
||
|
||
function normalizeCookieDomain(domain = '') {
|
||
const d = String(domain || '').trim();
|
||
if (!d) return '.goofish.com';
|
||
if (/^(goofish\.com|www\.goofish\.com|2\.taobao\.com|taobao\.com|login\.taobao\.com|alibaba\.com)$/i.test(d)) {
|
||
return `.${d.replace(/^\./, '')}`;
|
||
}
|
||
return d;
|
||
}
|
||
|
||
function normalizeCookie(cookie) {
|
||
if (!cookie || typeof cookie !== 'object') return null;
|
||
const name = String(cookie.name || '').trim();
|
||
const value = cookie.value == null ? '' : String(cookie.value);
|
||
if (!name) return null;
|
||
|
||
const normalized = {
|
||
name,
|
||
value,
|
||
domain: normalizeCookieDomain(cookie.domain),
|
||
path: cookie.path || '/',
|
||
httpOnly: !!cookie.httpOnly,
|
||
secure: cookie.secure !== false,
|
||
sameSite: ['Strict', 'Lax', 'None'].includes(cookie.sameSite) ? cookie.sameSite : 'Lax',
|
||
};
|
||
|
||
const rawExpires = cookie.expires ?? cookie.expirationDate ?? cookie.expiry;
|
||
if (rawExpires != null && rawExpires !== -1 && rawExpires !== 'Session') {
|
||
const expires = Number(rawExpires);
|
||
if (Number.isFinite(expires) && expires > 0) {
|
||
normalized.expires = expires > 1e12 ? Math.floor(expires / 1000) : Math.floor(expires);
|
||
}
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
export function parseCookiesPayload(payload) {
|
||
let data = payload;
|
||
if (typeof data === 'string') data = JSON.parse(data);
|
||
const cookies = Array.isArray(data)
|
||
? data
|
||
: Array.isArray(data?.cookies)
|
||
? data.cookies
|
||
: Array.isArray(data?.data)
|
||
? data.data
|
||
: null;
|
||
|
||
if (!cookies) throw new Error('Cookie JSON 格式不正确:请上传 Cookie 数组,或包含 cookies 数组的 JSON 对象');
|
||
const normalized = cookies.map(normalizeCookie).filter(Boolean);
|
||
if (!normalized.length) throw new Error('没有识别到有效 Cookie;每条 Cookie 至少需要 name/value/domain');
|
||
return normalized;
|
||
}
|
||
|
||
export async function importCookiesFromJson(payload, emitLog = () => {}) {
|
||
const cookies = parseCookiesPayload(payload);
|
||
const ctx = await getBrowserContext();
|
||
await ctx.addCookies(cookies);
|
||
emitLog(`✅ 已导入 ${cookies.length} 条 Cookie 到 Playwright 持久化浏览器`);
|
||
|
||
const storedCookies = await ctx.cookies().catch(() => []);
|
||
const goofishCookies = storedCookies.filter(c => /(^|\.)goofish\.com$|(^|\.)taobao\.com$|(^|\.)alibaba\.com$/.test(c.domain));
|
||
return {
|
||
imported: cookies.length,
|
||
status: {
|
||
loggedIn: goofishCookies.length > 5,
|
||
loginWall: false,
|
||
loginTextVisible: false,
|
||
nickVisible: false,
|
||
avatarVisible: false,
|
||
cardCount: 0,
|
||
cookieCount: goofishCookies.length,
|
||
accountName: '',
|
||
nickCandidates: [],
|
||
avatarUrl: '',
|
||
url: 'cookie-import://local',
|
||
title: 'Cookie 已导入,点击“检测登录状态”可联网验证',
|
||
checkedAt: new Date().toISOString(),
|
||
storage: 'browser-data/',
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function openLoginPage(emitLog = () => {}) {
|
||
const ctx = await getBrowserContext();
|
||
const page = ctx.pages()[0] || await ctx.newPage();
|
||
emitLog('正在打开闲鱼登录/首页窗口...');
|
||
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await sleep(2000);
|
||
const status = await getLoginStatus(page);
|
||
if (status.loggedIn) emitLog('✅ 已检测到登录状态,cookie 已保存在 browser-data 中');
|
||
else emitLog('⚠️ 还未登录:请在弹出的 Chromium 窗口中完成扫码/验证码登录,完成后点击 Web 上的“检测登录状态”');
|
||
return status;
|
||
}
|
||
|
||
function isGoofishOrLoginPage(page) {
|
||
const url = page.url();
|
||
return /goofish\.com|login\.taobao\.com|havanaone/.test(url);
|
||
}
|
||
|
||
async function clickIfVisible(page, selector, timeout = 1000) {
|
||
const loc = page.locator(selector).first();
|
||
if (await loc.isVisible({ timeout }).catch(() => false)) {
|
||
await loc.click({ timeout: 3000 }).catch(() => {});
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function findQrLikeLocator(page) {
|
||
const selectors = [
|
||
'canvas',
|
||
'img[src^="data:image"]',
|
||
'img[src*="qr"]',
|
||
'img[src*="qrcode"]',
|
||
'[class*="qrcode"] canvas',
|
||
'[class*="qrcode"] img',
|
||
'[class*="qr"] canvas',
|
||
'[class*="qr"] img',
|
||
'[class*="login"] canvas',
|
||
'[class*="login"] img',
|
||
];
|
||
for (const selector of selectors) {
|
||
const loc = page.locator(selector).first();
|
||
if (await loc.isVisible({ timeout: 1200 }).catch(() => false)) return loc;
|
||
}
|
||
for (const frame of page.frames()) {
|
||
for (const selector of selectors) {
|
||
const loc = frame.locator(selector).first();
|
||
if (await loc.isVisible({ timeout: 800 }).catch(() => false)) return loc;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export async function getLoginQr(emitLog = () => {}) {
|
||
const ctx = await getBrowserContext();
|
||
const page = ctx.pages().find(isGoofishOrLoginPage) || ctx.pages()[0] || await ctx.newPage();
|
||
emitLog('正在从闲鱼官网入口打开登录页;如跳转到淘宝/支付宝统一登录,会在日志中标明来源。');
|
||
await page.goto(QR_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await sleep(2500);
|
||
|
||
let clicked = false;
|
||
for (const selector of ['text=立即登录', 'text=登录', 'button:has-text("登录")', 'a:has-text("登录")']) {
|
||
if (await clickIfVisible(page, selector)) {
|
||
clicked = true;
|
||
await sleep(2500);
|
||
break;
|
||
}
|
||
}
|
||
|
||
for (const selector of ['text=扫码登录', 'text=二维码登录', 'text=使用二维码登录', '.icon-qrcode', '[class*=qrcode]']) {
|
||
if (await clickIfVisible(page, selector)) {
|
||
await sleep(1500);
|
||
break;
|
||
}
|
||
}
|
||
|
||
const currentUrl = page.url();
|
||
const redirectedProvider = /login\.taobao\.com|havanaone/.test(currentUrl) ? '淘宝/阿里统一登录' : '闲鱼 Web 登录入口';
|
||
const status = await getLoginStatus(page);
|
||
let qrImage = '';
|
||
let screenshotKind = '';
|
||
if (!status.loggedIn) {
|
||
const qrLike = await findQrLikeLocator(page);
|
||
const modal = page.locator('[class*="login-modal"], [class*="Login"], [class*="qrcode"], [class*="qr"], iframe').first();
|
||
const target = qrLike || (await modal.isVisible({ timeout: 1500 }).catch(() => false) ? modal : page.locator('body'));
|
||
screenshotKind = qrLike ? '二维码区域' : '登录面板截图';
|
||
const buf = await target.screenshot({ type: 'png', timeout: 8000 }).catch(() => null);
|
||
if (buf) qrImage = `data:image/png;base64,${buf.toString('base64')}`;
|
||
}
|
||
|
||
if (status.loggedIn) emitLog('✅ 当前账号已登录,无需扫码');
|
||
else emitLog(`已从${redirectedProvider}生成${screenshotKind || '登录截图'}${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请扫码后点“刷新账号状态”。当前 URL:${currentUrl}`);
|
||
return { ...status, qrImage, qrCheckedAt: new Date().toISOString(), qrSource: redirectedProvider, qrUrl: currentUrl, qrScreenshotKind: screenshotKind };
|
||
}
|
||
|
||
export async function getLoginStatus(existingPage = null) {
|
||
const ctx = await getBrowserContext();
|
||
const page = existingPage || ctx.pages()[0] || await ctx.newPage();
|
||
if (!existingPage) {
|
||
await page.goto(SEARCH_PROBE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(async () => {
|
||
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
});
|
||
await sleep(3000);
|
||
}
|
||
|
||
const bodyText = await page.locator('body').innerText({ timeout: 3000 }).catch(() => '');
|
||
const hasLoginWall = /登录后可以更懂你|立即登录|请登录/.test(bodyText);
|
||
const loginTextVisible = await page.locator('text=登录').first().isVisible({ timeout: 1000 }).catch(() => false);
|
||
const nickVisible = await page.locator('[class*="nick--"]').first().isVisible({ timeout: 1500 }).catch(() => false);
|
||
const avatarVisible = await page.locator('[class*="user-order-container"] img').first().isVisible({ timeout: 1500 }).catch(() => false);
|
||
const cardCount = await page.locator('a[class*="feeds-item-wrap"], a[href*="/item"], a[href*="id="]').count().catch(() => 0);
|
||
const cookies = await ctx.cookies().catch(() => []);
|
||
const goofishCookies = cookies.filter(c => /(^|\.)goofish\.com$|(^|\.)taobao\.com$|(^|\.)alibaba\.com$/.test(c.domain));
|
||
const likelyLoggedIn = !hasLoginWall && !loginTextVisible && (nickVisible || avatarVisible || cardCount > 0 || goofishCookies.length > 5);
|
||
const accountInfo = likelyLoggedIn
|
||
? await extractAccountInfo(page, bodyText)
|
||
: { accountName: '', nickCandidates: [], avatarUrl: '', pageTitle: await page.title().catch(() => '') };
|
||
|
||
return {
|
||
loggedIn: likelyLoggedIn,
|
||
loginWall: hasLoginWall,
|
||
loginTextVisible,
|
||
nickVisible,
|
||
avatarVisible,
|
||
cardCount,
|
||
cookieCount: goofishCookies.length,
|
||
accountName: accountInfo.accountName,
|
||
nickCandidates: accountInfo.nickCandidates,
|
||
avatarUrl: accountInfo.avatarUrl,
|
||
url: page.url(),
|
||
title: accountInfo.pageTitle,
|
||
checkedAt: new Date().toISOString(),
|
||
storage: 'browser-data/',
|
||
};
|
||
}
|
||
|
||
export async function checkLogin(emitLog) {
|
||
const ctx = await getBrowserContext();
|
||
const page = ctx.pages()[0] || await ctx.newPage();
|
||
|
||
emitLog('正在打开闲鱼首页...');
|
||
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await sleep(3000);
|
||
|
||
for (let attempt = 0; attempt < 5; attempt++) {
|
||
const status = await getLoginStatus(page);
|
||
if (status.loggedIn) {
|
||
emitLog(`✅ 登录状态正常,cookie 数 ${status.cookieCount},持久化目录 browser-data/`);
|
||
return true;
|
||
}
|
||
|
||
const remaining = 5 - attempt - 1;
|
||
emitLog(`⚠️ 未检测到登录状态,请在浏览器中手动登录(剩余${remaining}次重试机会)`);
|
||
emitLog('等待20秒后重新检测...');
|
||
await sleep(20000);
|
||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
|
||
await sleep(3000);
|
||
}
|
||
|
||
emitLog('❌ 多次检测均未登录,请登录后重新启动任务');
|
||
return false;
|
||
}
|