write-shared.js
1 import { readFile, stat } from 'node:fs/promises'; 2 import { CliError } from '@jackwener/opencli/errors'; 3 const RESULT_ROW_RESERVED_KEYS = new Set(['status', 'outcome', 'message', 'target_type', 'target']); 4 const NAV_SCOPE_SELECTOR = 'header, nav, [role="banner"], [role="navigation"]'; 5 const PROFILE_LINK_SELECTOR = 'a[href^="/people/"]'; 6 const AVATAR_SELECTOR = 'img, [class*="Avatar"], [data-testid*="avatar" i], [aria-label*="头像"]'; 7 const SELF_LABEL_TOKENS = ['我', '我的', '个人主页']; 8 const EXPLICIT_IDENTITY_META_TOKEN_GROUPS = [ 9 ['self'], 10 ['current', 'user'], 11 ['account', 'profile'], 12 ['my', 'profile'], 13 ['my', 'account'], 14 ]; 15 const IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS = JSON.stringify(EXPLICIT_IDENTITY_META_TOKEN_GROUPS); 16 function defaultFileReaderDeps() { 17 return { 18 readFile, 19 stat: (path) => stat(path), 20 decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw), 21 }; 22 } 23 function hasExplicitIdentityLabel(text) { 24 const normalized = text.toLowerCase(); 25 return SELF_LABEL_TOKENS.some((token) => text.includes(token)) || normalized.includes('my profile') || normalized.includes('my account'); 26 } 27 function tokenizeIdentityMeta(text) { 28 return text 29 .toLowerCase() 30 .split(/[^a-z0-9]+/) 31 .filter(Boolean); 32 } 33 function hasExplicitIdentityMeta(text) { 34 const tokens = new Set(tokenizeIdentityMeta(text)); 35 return EXPLICIT_IDENTITY_META_TOKEN_GROUPS.some((group) => group.every((token) => tokens.has(token))); 36 } 37 function isIdentityRootLike(value) { 38 return typeof value === 'object' && value !== null && 'querySelectorAll' in value 39 && typeof value.querySelectorAll === 'function'; 40 } 41 function isIdentityNodeLike(value) { 42 return typeof value === 'object' && value !== null 43 && 'getAttribute' in value 44 && 'querySelector' in value 45 && typeof value.getAttribute === 'function' 46 && typeof value.querySelector === 'function'; 47 } 48 function resolveSlugFromState(state) { 49 const slugFromState = state?.topstory?.me?.slug 50 || state?.me?.slug 51 || state?.initialState?.me?.slug; 52 return typeof slugFromState === 'string' && slugFromState ? slugFromState : null; 53 } 54 function getSlugFromIdentityLink(node, allowAvatarOnly) { 55 const href = node.getAttribute('href') || ''; 56 const match = href.match(/^\/people\/([A-Za-z0-9_-]+)/); 57 if (!match) 58 return null; 59 const aria = node.getAttribute('aria-label') || ''; 60 const title = node.getAttribute('title') || ''; 61 const testid = node.getAttribute('data-testid') || ''; 62 const className = node.getAttribute('class') || ''; 63 const rel = node.getAttribute('rel') || ''; 64 const identityLabel = `${aria} ${title} ${node.textContent || ''}`; 65 const identityMeta = `${testid} ${className} ${rel}`; 66 const hasAvatar = Boolean(node.querySelector(AVATAR_SELECTOR)); 67 const isExplicitIdentityLabel = hasExplicitIdentityLabel(identityLabel); 68 const isExplicitIdentityMeta = hasExplicitIdentityMeta(identityMeta); 69 if (isExplicitIdentityLabel || isExplicitIdentityMeta) 70 return match[1]; 71 if (allowAvatarOnly && hasAvatar) 72 return match[1]; 73 return null; 74 } 75 function findCurrentUserSlugFromRoots(roots, allowAvatarOnly) { 76 for (const root of roots) { 77 for (const node of Array.from(root.querySelectorAll(PROFILE_LINK_SELECTOR)).filter(isIdentityNodeLike)) { 78 const slug = getSlugFromIdentityLink(node, allowAvatarOnly); 79 if (slug) 80 return slug; 81 } 82 } 83 return null; 84 } 85 export function resolveCurrentUserSlugFromDom(state, documentRoot) { 86 const slugFromState = resolveSlugFromState(state); 87 if (slugFromState) 88 return slugFromState; 89 const navScopes = Array.from(documentRoot.querySelectorAll(NAV_SCOPE_SELECTOR)).filter(isIdentityRootLike); 90 return findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([documentRoot], false); 91 } 92 export function requireExecute(kwargs) { 93 if (!kwargs.execute) { 94 throw new CliError('INVALID_INPUT', 'This Zhihu write command requires --execute'); 95 } 96 } 97 export async function resolvePayload(kwargs, deps = defaultFileReaderDeps()) { 98 const text = typeof kwargs.text === 'string' ? kwargs.text : undefined; 99 const file = typeof kwargs.file === 'string' ? kwargs.file : undefined; 100 if (text && file) { 101 throw new CliError('INVALID_INPUT', 'Use either <text> or --file, not both'); 102 } 103 let resolved = text ?? ''; 104 if (file) { 105 let fileStat; 106 try { 107 fileStat = await deps.stat(file); 108 } 109 catch { 110 throw new CliError('INVALID_INPUT', `File not found: ${file}`); 111 } 112 if (!fileStat.isFile()) { 113 throw new CliError('INVALID_INPUT', `File must be a readable text file: ${file}`); 114 } 115 let raw; 116 try { 117 raw = await deps.readFile(file); 118 } 119 catch { 120 throw new CliError('INVALID_INPUT', `File could not be read: ${file}`); 121 } 122 try { 123 resolved = deps.decodeUtf8(raw); 124 } 125 catch { 126 throw new CliError('INVALID_INPUT', `File could not be decoded as UTF-8 text: ${file}`); 127 } 128 } 129 if (!resolved.trim()) { 130 throw new CliError('INVALID_INPUT', 'Payload cannot be empty or whitespace only'); 131 } 132 return resolved; 133 } 134 function buildResolveCurrentUserIdentityJs() { 135 return `(() => { 136 const selfLabelTokens = ${JSON.stringify(SELF_LABEL_TOKENS)}; 137 const explicitIdentityMetaTokenGroups = ${IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS}; 138 const navScopeSelector = ${JSON.stringify(NAV_SCOPE_SELECTOR)}; 139 const profileLinkSelector = ${JSON.stringify(PROFILE_LINK_SELECTOR)}; 140 const avatarSelector = ${JSON.stringify(AVATAR_SELECTOR)}; 141 142 const hasExplicitIdentityLabel = (text) => { 143 const normalized = String(text || '').toLowerCase(); 144 return selfLabelTokens.some((token) => String(text || '').includes(token)) 145 || normalized.includes('my profile') 146 || normalized.includes('my account'); 147 }; 148 149 const tokenizeIdentityMeta = (text) => String(text || '') 150 .toLowerCase() 151 .split(/[^a-z0-9]+/) 152 .filter(Boolean); 153 154 const hasExplicitIdentityMeta = (text) => { 155 const tokens = new Set(tokenizeIdentityMeta(text)); 156 return explicitIdentityMetaTokenGroups.some((group) => group.every((token) => tokens.has(token))); 157 }; 158 159 const getSlugFromIdentityLink = (node, allowAvatarOnly) => { 160 const href = node.getAttribute('href') || ''; 161 const match = href.match(/^\\/people\\/([A-Za-z0-9_-]+)/); 162 if (!match) return null; 163 164 const aria = node.getAttribute('aria-label') || ''; 165 const title = node.getAttribute('title') || ''; 166 const testid = node.getAttribute('data-testid') || ''; 167 const className = node.getAttribute('class') || ''; 168 const rel = node.getAttribute('rel') || ''; 169 const identityLabel = \`\${aria} \${title} \${node.textContent || ''}\`; 170 const identityMeta = \`\${testid} \${className} \${rel}\`; 171 const hasAvatar = Boolean(node.querySelector(avatarSelector)); 172 173 if (hasExplicitIdentityLabel(identityLabel) || hasExplicitIdentityMeta(identityMeta)) return match[1]; 174 if (allowAvatarOnly && hasAvatar) return match[1]; 175 return null; 176 }; 177 178 const findCurrentUserSlugFromRoots = (roots, allowAvatarOnly) => { 179 for (const root of roots) { 180 for (const node of Array.from(root.querySelectorAll(profileLinkSelector))) { 181 const slug = getSlugFromIdentityLink(node, allowAvatarOnly); 182 if (slug) return slug; 183 } 184 } 185 return null; 186 }; 187 188 const scopedGlobal = globalThis; 189 const state = scopedGlobal.__INITIAL_STATE__ || (scopedGlobal.window && scopedGlobal.window.__INITIAL_STATE__) || null; 190 const slugFromState = state && (state.topstory && state.topstory.me && state.topstory.me.slug) 191 || (state && state.me && state.me.slug) 192 || (state && state.initialState && state.initialState.me && state.initialState.me.slug); 193 if (typeof slugFromState === 'string' && slugFromState) return { slug: slugFromState }; 194 195 const navScopes = Array.from(document.querySelectorAll(navScopeSelector)); 196 const slug = findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([document], false); 197 return slug ? { slug } : null; 198 })()`; 199 } 200 export async function resolveCurrentUserIdentity(page) { 201 const identity = await page.evaluate(buildResolveCurrentUserIdentityJs()); 202 if (!identity?.slug) { 203 throw new CliError('ACTION_NOT_AVAILABLE', 'Could not resolve the logged-in Zhihu user identity before write'); 204 } 205 return identity.slug; 206 } 207 export function buildResultRow(message, targetType, target, outcome, extra = {}) { 208 for (const key of Object.keys(extra)) { 209 if (RESULT_ROW_RESERVED_KEYS.has(key)) { 210 throw new CliError('INVALID_INPUT', `Result extra field cannot overwrite reserved key: ${key}`); 211 } 212 } 213 return [{ status: 'success', outcome, message, target_type: targetType, target, ...extra }]; 214 } 215 export const __test__ = { 216 requireExecute, 217 resolvePayload, 218 resolveCurrentUserIdentity, 219 resolveCurrentUserSlugFromDom, 220 buildResultRow, 221 };