76 lines
5.5 KiB
JavaScript
76 lines
5.5 KiB
JavaScript
// Read visible UI signals only. Never inspect cookies, storage, form values or
|
|
// private framework state. This is a login hint, not account identity proof.
|
|
export const LOGIN_EVIDENCE = Object.freeze([
|
|
'visible_current_account_navigation', 'visible_login_dialog',
|
|
'visible_login_entry', 'visible_verification_widget',
|
|
'no_conclusive_visible_evidence', 'unsupported_platform', 'inspection_unavailable',
|
|
]);
|
|
|
|
export async function inspectVisibleSessionSignals(page) {
|
|
return page.evaluate(() => {
|
|
const visible = element => {
|
|
const rect = element.getBoundingClientRect();
|
|
if (rect.width <= 0 || rect.height <= 0 || rect.bottom <= 0 || rect.right <= 0 || rect.top >= innerHeight || rect.left >= innerWidth) return false;
|
|
let top = Math.max(0, rect.top), bottom = Math.min(innerHeight, rect.bottom);
|
|
let left = Math.max(0, rect.left), right = Math.min(innerWidth, rect.right);
|
|
for (let node = element; node; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility) || Number(style.opacity) === 0 || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true') return false;
|
|
if (node !== element) {
|
|
const box = node.getBoundingClientRect();
|
|
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowY)) { top = Math.max(top, box.top); bottom = Math.min(bottom, box.bottom); }
|
|
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowX)) { left = Math.max(left, box.left); right = Math.min(right, box.right); }
|
|
}
|
|
}
|
|
return bottom > top && right > left;
|
|
};
|
|
const label = element => {
|
|
const walk = node => {
|
|
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
|
if (node.nodeType !== Node.ELEMENT_NODE || node.matches('input,textarea,select,script,style,noscript,iframe,[contenteditable=true]') || !visible(node)) return '';
|
|
return [...node.childNodes].map(walk).join(' ');
|
|
};
|
|
return [walk(element), element.getAttribute('aria-label'), element.getAttribute('title')].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 1800);
|
|
};
|
|
const pathname = element => {
|
|
try { return new URL(element.getAttribute('href'), location.href).pathname; } catch { return ''; }
|
|
};
|
|
const overlays = [...document.querySelectorAll('[role=dialog],[aria-modal=true],dialog[open],[class*="login-modal" i],[class*="login-container" i],[class*="login-dialog" i]')].filter(visible);
|
|
const widgets = [...document.querySelectorAll('[class*="captcha" i],[id*="captcha" i],[class*="verification" i],[id*="verification" i],iframe[title*="验证"],iframe[title*="captcha" i],input[autocomplete="one-time-code"]')].filter(visible);
|
|
const verification = widgets.some(element => element.matches('input,iframe') || /安全验证|请完成验证|拖动|滑动|点击.*验证|验证码|captcha|verification/i.test(label(element)));
|
|
const loginDialog = overlays.some(element => /扫码登录|扫描二维码|手机号登录|验证码登录|账号登录|登录后|登录小红书|log\s?in|sign\s?in/i.test(label(element)));
|
|
const entries = [...document.querySelectorAll('button,a,[role=button],[class*="login-btn" i],[class*="login-button" i]')].filter(visible);
|
|
const loginEntry = entries.some(element => /^(?:登录|立即登录|去登录|登录\s*[/·]\s*注册|登录小红书|log\s?in|sign\s?in)$/i.test(label(element)));
|
|
const profiles = [...document.querySelectorAll('a[href*="/user/profile/"]')].filter(element => visible(element) && /^\/user\/profile\/[A-Za-z0-9_-]{6,}\/?$/.test(pathname(element)));
|
|
const currentAccountNavigation = profiles.some(profile => {
|
|
const container = profile.closest('nav,aside,[role=navigation],ul');
|
|
if (!container || !visible(container)) return false;
|
|
const links = [...container.querySelectorAll('a[href]')].filter(visible);
|
|
// A note author's public profile link is not proof that the viewer signed
|
|
// in. Require the profile link to belong to the visible main navigation.
|
|
return links.some(link => pathname(link) === '/explore') && links.some(link => /^\/(?:message|notification)(?:\/|$)/.test(pathname(link)));
|
|
});
|
|
return { verification, loginDialog, loginEntry, currentAccountNavigation };
|
|
});
|
|
}
|
|
|
|
export function classifyLoginSignals(signals) {
|
|
if (signals.verification) return { state: 'verification_required', evidence: ['visible_verification_widget'] };
|
|
if (signals.loginDialog) return { state: 'logged_out', evidence: ['visible_login_dialog'] };
|
|
if (signals.loginEntry && signals.currentAccountNavigation) return { state: 'unknown', evidence: ['visible_login_entry', 'visible_current_account_navigation'] };
|
|
if (signals.loginEntry) return { state: 'logged_out', evidence: ['visible_login_entry'] };
|
|
if (signals.currentAccountNavigation) return { state: 'logged_in', evidence: ['visible_current_account_navigation'] };
|
|
return { state: 'unknown', evidence: ['no_conclusive_visible_evidence'] };
|
|
}
|
|
|
|
export async function detectSession(page) {
|
|
const result = value => ({ ...value, basis: 'visible_page', observed_at: new Date().toISOString() });
|
|
try {
|
|
const host = new URL(page.url()).hostname;
|
|
if (host !== 'xiaohongshu.com' && !host.endsWith('.xiaohongshu.com')) return result({ state: 'unknown', evidence: ['unsupported_platform'] });
|
|
return result(classifyLoginSignals(await inspectVisibleSessionSignals(page)));
|
|
} catch {
|
|
return result({ state: 'unknown', evidence: ['inspection_unavailable'] });
|
|
}
|
|
}
|