import { readFile, stat } from 'node:fs/promises'; import { CliError } from '@jackwener/opencli/errors'; const RESULT_ROW_RESERVED_KEYS = new Set(['status', 'outcome', 'message', 'target_type', 'target']); const NAV_SCOPE_SELECTOR = 'header, nav, [role="banner"], [role="navigation"]'; const PROFILE_LINK_SELECTOR = 'a[href^="/people/"]'; const AVATAR_SELECTOR = 'img, [class*="Avatar"], [data-testid*="avatar" i], [aria-label*="头像"]'; const SELF_LABEL_TOKENS = ['我', '我的', '个人主页']; const EXPLICIT_IDENTITY_META_TOKEN_GROUPS = [ ['self'], ['current', 'user'], ['account', 'profile'], ['my', 'profile'], ['my', 'account'], ]; const IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS = JSON.stringify(EXPLICIT_IDENTITY_META_TOKEN_GROUPS); function defaultFileReaderDeps() { return { readFile, stat: (path) => stat(path), decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw), }; } function hasExplicitIdentityLabel(text) { const normalized = text.toLowerCase(); return SELF_LABEL_TOKENS.some((token) => text.includes(token)) || normalized.includes('my profile') || normalized.includes('my account'); } function tokenizeIdentityMeta(text) { return text .toLowerCase() .split(/[^a-z0-9]+/) .filter(Boolean); } function hasExplicitIdentityMeta(text) { const tokens = new Set(tokenizeIdentityMeta(text)); return EXPLICIT_IDENTITY_META_TOKEN_GROUPS.some((group) => group.every((token) => tokens.has(token))); } function isIdentityRootLike(value) { return typeof value === 'object' && value !== null && 'querySelectorAll' in value && typeof value.querySelectorAll === 'function'; } function isIdentityNodeLike(value) { return typeof value === 'object' && value !== null && 'getAttribute' in value && 'querySelector' in value && typeof value.getAttribute === 'function' && typeof value.querySelector === 'function'; } function resolveSlugFromState(state) { const slugFromState = state?.topstory?.me?.slug || state?.me?.slug || state?.initialState?.me?.slug; return typeof slugFromState === 'string' && slugFromState ? slugFromState : null; } function getSlugFromIdentityLink(node, allowAvatarOnly) { const href = node.getAttribute('href') || ''; const match = href.match(/^\/people\/([A-Za-z0-9_-]+)/); if (!match) return null; const aria = node.getAttribute('aria-label') || ''; const title = node.getAttribute('title') || ''; const testid = node.getAttribute('data-testid') || ''; const className = node.getAttribute('class') || ''; const rel = node.getAttribute('rel') || ''; const identityLabel = `${aria} ${title} ${node.textContent || ''}`; const identityMeta = `${testid} ${className} ${rel}`; const hasAvatar = Boolean(node.querySelector(AVATAR_SELECTOR)); const isExplicitIdentityLabel = hasExplicitIdentityLabel(identityLabel); const isExplicitIdentityMeta = hasExplicitIdentityMeta(identityMeta); if (isExplicitIdentityLabel || isExplicitIdentityMeta) return match[1]; if (allowAvatarOnly && hasAvatar) return match[1]; return null; } function findCurrentUserSlugFromRoots(roots, allowAvatarOnly) { for (const root of roots) { for (const node of Array.from(root.querySelectorAll(PROFILE_LINK_SELECTOR)).filter(isIdentityNodeLike)) { const slug = getSlugFromIdentityLink(node, allowAvatarOnly); if (slug) return slug; } } return null; } export function resolveCurrentUserSlugFromDom(state, documentRoot) { const slugFromState = resolveSlugFromState(state); if (slugFromState) return slugFromState; const navScopes = Array.from(documentRoot.querySelectorAll(NAV_SCOPE_SELECTOR)).filter(isIdentityRootLike); return findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([documentRoot], false); } export function requireExecute(kwargs) { if (!kwargs.execute) { throw new CliError('INVALID_INPUT', 'This Zhihu write command requires --execute'); } } export async function resolvePayload(kwargs, deps = defaultFileReaderDeps()) { const text = typeof kwargs.text === 'string' ? kwargs.text : undefined; const file = typeof kwargs.file === 'string' ? kwargs.file : undefined; if (text && file) { throw new CliError('INVALID_INPUT', 'Use either or --file, not both'); } let resolved = text ?? ''; if (file) { let fileStat; try { fileStat = await deps.stat(file); } catch { throw new CliError('INVALID_INPUT', `File not found: ${file}`); } if (!fileStat.isFile()) { throw new CliError('INVALID_INPUT', `File must be a readable text file: ${file}`); } let raw; try { raw = await deps.readFile(file); } catch { throw new CliError('INVALID_INPUT', `File could not be read: ${file}`); } try { resolved = deps.decodeUtf8(raw); } catch { throw new CliError('INVALID_INPUT', `File could not be decoded as UTF-8 text: ${file}`); } } if (!resolved.trim()) { throw new CliError('INVALID_INPUT', 'Payload cannot be empty or whitespace only'); } return resolved; } function buildResolveCurrentUserIdentityJs() { return `(() => { const selfLabelTokens = ${JSON.stringify(SELF_LABEL_TOKENS)}; const explicitIdentityMetaTokenGroups = ${IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS}; const navScopeSelector = ${JSON.stringify(NAV_SCOPE_SELECTOR)}; const profileLinkSelector = ${JSON.stringify(PROFILE_LINK_SELECTOR)}; const avatarSelector = ${JSON.stringify(AVATAR_SELECTOR)}; const hasExplicitIdentityLabel = (text) => { const normalized = String(text || '').toLowerCase(); return selfLabelTokens.some((token) => String(text || '').includes(token)) || normalized.includes('my profile') || normalized.includes('my account'); }; const tokenizeIdentityMeta = (text) => String(text || '') .toLowerCase() .split(/[^a-z0-9]+/) .filter(Boolean); const hasExplicitIdentityMeta = (text) => { const tokens = new Set(tokenizeIdentityMeta(text)); return explicitIdentityMetaTokenGroups.some((group) => group.every((token) => tokens.has(token))); }; const getSlugFromIdentityLink = (node, allowAvatarOnly) => { const href = node.getAttribute('href') || ''; const match = href.match(/^\\/people\\/([A-Za-z0-9_-]+)/); if (!match) return null; const aria = node.getAttribute('aria-label') || ''; const title = node.getAttribute('title') || ''; const testid = node.getAttribute('data-testid') || ''; const className = node.getAttribute('class') || ''; const rel = node.getAttribute('rel') || ''; const identityLabel = \`\${aria} \${title} \${node.textContent || ''}\`; const identityMeta = \`\${testid} \${className} \${rel}\`; const hasAvatar = Boolean(node.querySelector(avatarSelector)); if (hasExplicitIdentityLabel(identityLabel) || hasExplicitIdentityMeta(identityMeta)) return match[1]; if (allowAvatarOnly && hasAvatar) return match[1]; return null; }; const findCurrentUserSlugFromRoots = (roots, allowAvatarOnly) => { for (const root of roots) { for (const node of Array.from(root.querySelectorAll(profileLinkSelector))) { const slug = getSlugFromIdentityLink(node, allowAvatarOnly); if (slug) return slug; } } return null; }; const scopedGlobal = globalThis; const state = scopedGlobal.__INITIAL_STATE__ || (scopedGlobal.window && scopedGlobal.window.__INITIAL_STATE__) || null; const slugFromState = state && (state.topstory && state.topstory.me && state.topstory.me.slug) || (state && state.me && state.me.slug) || (state && state.initialState && state.initialState.me && state.initialState.me.slug); if (typeof slugFromState === 'string' && slugFromState) return { slug: slugFromState }; const navScopes = Array.from(document.querySelectorAll(navScopeSelector)); const slug = findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([document], false); if (slug) return { slug }; var avatarImgs = document.querySelectorAll('header img[alt*="\\u4e3b\\u9875"]'); for (var ai = 0; ai < avatarImgs.length; ai++) { var altMatch = (avatarImgs[ai].alt || '').match(/\\u70b9\\u51fb\\u6253\\u5f00(.+?)\\u7684\\u4e3b\\u9875/); if (altMatch) return { slug: altMatch[1] }; } return null; })()`; } export async function resolveCurrentUserIdentity(page) { const identity = await page.evaluate(buildResolveCurrentUserIdentityJs()); if (!identity?.slug) { throw new CliError('ACTION_NOT_AVAILABLE', 'Could not resolve the logged-in Zhihu user identity before write'); } return identity.slug; } export function buildResultRow(message, targetType, target, outcome, extra = {}) { for (const key of Object.keys(extra)) { if (RESULT_ROW_RESERVED_KEYS.has(key)) { throw new CliError('INVALID_INPUT', `Result extra field cannot overwrite reserved key: ${key}`); } } return [{ status: 'success', outcome, message, target_type: targetType, target, ...extra }]; } export const __test__ = { requireExecute, resolvePayload, resolveCurrentUserIdentity, resolveCurrentUserSlugFromDom, buildResultRow, };