Files
xianyu-hunter-hermes-skill/project/xianyu-hunter/lib/login.mjs
T

279 lines
11 KiB
JavaScript
Raw 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 { 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;
}