/ clis / zhihu / write-shared.js
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  };