From 4df54912a14bd7323a3b77db44b09ed0114a29ef Mon Sep 17 00:00:00 2001 From: Jeff Chen Date: Mon, 11 May 2026 01:25:01 +0800 Subject: [PATCH 01/12] fix(extension): ignore stale daemon websocket closes --- extension/dist/background.js | 20 ++++++++++------- extension/src/background.test.ts | 38 ++++++++++++++++++++++++++++++-- extension/src/background.ts | 20 ++++++++++------- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 8a21a8c14..790c9ab1e 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -698,44 +698,48 @@ async function connect() { } catch { return; } + let thisWs; try { const contextId = await getCurrentContextId(); - ws = new WebSocket(DAEMON_WS_URL); + thisWs = new WebSocket(DAEMON_WS_URL); + ws = thisWs; currentContextId = contextId; } catch { scheduleReconnect(); return; } - ws.onopen = () => { + thisWs.onopen = () => { + if (ws !== thisWs) return; console.log("[opencli] Connected to daemon"); reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } - ws?.send(JSON.stringify({ + thisWs.send(JSON.stringify({ type: "hello", contextId: currentContextId, version: chrome.runtime.getManifest().version, compatRange: ">=1.7.0" })); }; - ws.onmessage = async (event) => { + thisWs.onmessage = async (event) => { try { const command = JSON.parse(event.data); const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); + thisWs.send(JSON.stringify(result)); } catch (err) { console.error("[opencli] Message handling error:", err); } }; - ws.onclose = () => { + thisWs.onclose = () => { + if (ws !== thisWs) return; console.log("[opencli] Disconnected from daemon"); ws = null; scheduleReconnect(); }; - ws.onerror = () => { - ws?.close(); + thisWs.onerror = () => { + thisWs.close(); }; } const MAX_EAGER_ATTEMPTS = 6; diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index d859dfd11..8ca548858 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -26,14 +26,20 @@ type MockTabGroup = { class MockWebSocket { static OPEN = 1; static CONNECTING = 0; + static instances: MockWebSocket[] = []; readyState = MockWebSocket.CONNECTING; + sent: string[] = []; onopen: (() => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onclose: (() => void) | null = null; onerror: (() => void) | null = null; - constructor(_url: string) {} - send(_data: string): void {} + constructor(_url: string) { + MockWebSocket.instances.push(this); + } + send(data: string): void { + this.sent.push(data); + } close(): void { this.onclose?.(); } @@ -189,6 +195,7 @@ describe('background tab isolation', () => { beforeEach(() => { vi.resetModules(); vi.useRealTimers(); + MockWebSocket.instances = []; vi.stubGlobal('WebSocket', MockWebSocket); }); @@ -590,6 +597,33 @@ describe('background tab isolation', () => { }); }); + it('keeps the active daemon connection when a superseded WebSocket closes later', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true }))); + + await import('./background'); + await vi.waitFor(() => { + expect(MockWebSocket.instances).toHaveLength(1); + }); + const firstWs = MockWebSocket.instances[0]; + firstWs.readyState = 3; + + const onAlarmListener = chrome.alarms.onAlarm.addListener.mock.calls[0][0]; + await onAlarmListener({ name: 'keepalive' }); + await vi.waitFor(() => { + expect(MockWebSocket.instances).toHaveLength(2); + }); + const secondWs = MockWebSocket.instances[1]; + + firstWs.onclose?.(); + secondWs.onmessage?.({ data: JSON.stringify({ id: 'sessions-after-stale-close', action: 'sessions' }) }); + + await vi.waitFor(() => { + expect(secondWs.sent.some((entry) => entry.includes('sessions-after-stale-close'))).toBe(true); + }); + }); + it('can execute concurrently on two pages in the same workspace', async () => { const { chrome, tabs } = createChromeMock(); tabs.push({ diff --git a/extension/src/background.ts b/extension/src/background.ts index a8eb76840..304156604 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -100,16 +100,19 @@ async function connect(): Promise { return; // daemon not running — skip WebSocket to avoid console noise } + let thisWs: WebSocket; try { const contextId = await getCurrentContextId(); - ws = new WebSocket(DAEMON_WS_URL); + thisWs = new WebSocket(DAEMON_WS_URL); + ws = thisWs; currentContextId = contextId; } catch { scheduleReconnect(); return; } - ws.onopen = () => { + thisWs.onopen = () => { + if (ws !== thisWs) return; console.log('[opencli] Connected to daemon'); reconnectAttempts = 0; // Reset on successful connection if (reconnectTimer) { @@ -117,7 +120,7 @@ async function connect(): Promise { reconnectTimer = null; } // Send version + compatibility range so the daemon can report mismatches to the CLI - ws?.send(JSON.stringify({ + thisWs.send(JSON.stringify({ type: 'hello', contextId: currentContextId, version: chrome.runtime.getManifest().version, @@ -125,24 +128,25 @@ async function connect(): Promise { })); }; - ws.onmessage = async (event) => { + thisWs.onmessage = async (event) => { try { const command = JSON.parse(event.data as string) as Command; const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); + thisWs.send(JSON.stringify(result)); } catch (err) { console.error('[opencli] Message handling error:', err); } }; - ws.onclose = () => { + thisWs.onclose = () => { + if (ws !== thisWs) return; console.log('[opencli] Disconnected from daemon'); ws = null; scheduleReconnect(); }; - ws.onerror = () => { - ws?.close(); + thisWs.onerror = () => { + thisWs.close(); }; } From 765967ed39a0d16e03d669a03fad657d2299fc16 Mon Sep 17 00:00:00 2001 From: "J.Chen" Date: Mon, 11 May 2026 02:38:06 +0800 Subject: [PATCH 02/12] fix(facebook/feed): add fallback extraction for empty article nodes Add fallback extraction for Facebook feed posts when [role=article] nodes exist but contain empty text. Includes diagnostic errors, content/author cleanup, nested-container dedupe, and an evaluate-script syntax regression test. --- clis/facebook/feed.js | 159 +++++++++++++++++++++++++++++-------- clis/facebook/feed.test.js | 25 ++++++ 2 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 clis/facebook/feed.test.js diff --git a/clis/facebook/feed.js b/clis/facebook/feed.js index a2bd538e0..ccf4ece5f 100644 --- a/clis/facebook/feed.js +++ b/clis/facebook/feed.js @@ -13,47 +13,138 @@ cli({ { navigate: { url: 'https://www.facebook.com/', settleMs: 4000 } }, { evaluate: `(() => { const limit = \${{ args.limit }}; - const posts = document.querySelectorAll('[role="article"]'); - return Array.from(posts) + + // ── Primary extraction via [role="article"] ────────────────────────── + const articleNodes = document.querySelectorAll('[role="article"]'); + const primaryPosts = Array.from(articleNodes) .filter(el => { const text = el.textContent.trim(); - // Filter out "People you may know" suggestions (both CN and EN) return text.length > 30 && !text.startsWith('可能认识') && !text.startsWith('People you may know') && !text.startsWith('People You May Know'); - }) - .slice(0, limit) - .map((el, i) => { - // Author from header link - const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a'); - const author = headerLink ? headerLink.textContent.trim() : ''; - - // Post text: grab visible spans, filter noise - const spans = Array.from(el.querySelectorAll('div[dir="auto"]')) - .map(s => s.textContent.trim()) - .filter(t => t.length > 10 && t.length < 500); - const content = spans.length > 0 ? spans[0] : ''; - - // Engagement: find like/comment/share counts (CN + EN) - const allText = el.textContent; - const likesMatch = allText.match(/所有心情:([\\d,.\\s]*[\\d万亿KMk]+)/) || - allText.match(/All:\\s*([\\d,.KMk]+)/) || - allText.match(/([\\d,.KMk]+)\\s*(?:likes?|reactions?)/i); - const commentsMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*条评论/) || - allText.match(/([\\d,.KMk]+)\\s*comments?/i); - const sharesMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*次分享/) || - allText.match(/([\\d,.KMk]+)\\s*shares?/i); - - return { - index: i + 1, - author: author.substring(0, 50), - content: content.replace(/\\n/g, ' ').substring(0, 120), - likes: likesMatch ? likesMatch[1] : '-', - comments: commentsMatch ? commentsMatch[1] : '-', - shares: sharesMatch ? sharesMatch[1] : '-', - }; }); + + // ── Fallback extraction via action buttons ──────────────────────────── + // Facebook periodically restructures its DOM so [role="article"] nodes + // exist but have empty textContent. When that happens we locate post + // boundaries via the Like/Comment action buttons, then walk up the DOM + // to the nearest ancestor that contains meaningful text. + function fallbackExtract() { + const main = document.querySelector('[role="main"]'); + if (!main) return null; + + const likeSelectors = [ + '[aria-label="Like"]', '[aria-label="赞"]', + '[aria-label="Comment"]', '[aria-label="评论"]', + ]; + const actionButtons = Array.from( + main.querySelectorAll(likeSelectors.join(',')) + ); + + const seen = new WeakSet(); + const containers = []; + for (const btn of actionButtons) { + let node = btn.parentElement; + let found = null; + for (let depth = 0; depth < 20 && node; depth++, node = node.parentElement) { + if (node.textContent.trim().length >= 80) { found = node; break; } + } + if (!found || seen.has(found)) continue; + seen.add(found); + containers.push(found); + } + return containers.length ? containers : null; + } + + // ── Extract fields from a post container ───────────────────────────── + function extractPost(el, i) { + // Try progressively broader selectors: heading links → role=link → any profile link → first substantial link + const authorLink = + el.querySelector('h2 a, h3 a, h4 a, strong a') || + el.querySelector('a[href*="/"][role="link"]') || + el.querySelector('a[href*="facebook.com/"]') || + Array.from(el.querySelectorAll('a[href]')).find(a => { + const t = a.textContent.trim(); + return t.length > 2 && t.length < 60 && !/^(like|comment|share|follow|\\d)/i.test(t); + }); + // Fallback for sponsored posts where the advertiser name is not in a link + const author = (authorLink ? authorLink.textContent.trim() : '') || + (() => { + const short = Array.from(el.querySelectorAll('[dir="auto"]')) + .map(s => s.textContent.trim()) + .find(t => t.length > 2 && t.length <= 60 && !t.startsWith('#')); + return short || ''; + })(); + + const seen = new Set(); + const dirAutos = Array.from(el.querySelectorAll('[dir="auto"]')) + .map(s => s.textContent.trim()) + .filter(t => t.length > 10 && t.length < 600 && !seen.has(t) && seen.add(t)); + const content = dirAutos.join(' '); + + const allText = el.textContent; + const likesMatch = allText.match(/所有心情:([\\d,.\\s]*[\\d万亿KMk]+)/) || + allText.match(/All:\\s*([\\d,.KMk]+)/) || + allText.match(/([\\d,.KMk]+)\\s*(?:likes?|reactions?)/i); + const commentsMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*条评论/) || + allText.match(/([\\d,.KMk]+)\\s*comments?/i); + const sharesMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*次分享/) || + allText.match(/([\\d,.KMk]+)\\s*shares?/i); + + return { + index: i + 1, + author: author.substring(0, 50), + content: content.replace(/\\n/g, ' ').substring(0, 120), + likes: likesMatch ? likesMatch[1] : '-', + comments: commentsMatch ? commentsMatch[1] : '-', + shares: sharesMatch ? sharesMatch[1] : '-', + }; + } + + // ── Route: primary alone if sufficient, else supplement with fallback ── + const isNotSuggestion = el => { + const t = el.textContent.trim(); + return !t.startsWith('可能认识') && !t.startsWith('People you may know') && !t.startsWith('People You May Know'); + }; + + if (primaryPosts.length >= limit) { + return primaryPosts.slice(0, limit).map((el, i) => extractPost(el, i)); + } + + const fallbackContainers = fallbackExtract(); + const fallbackPosts = fallbackContainers ? fallbackContainers.filter(isNotSuggestion) : []; + + if (primaryPosts.length > 0 || fallbackPosts.length > 0) { + const primarySet = new WeakSet(primaryPosts); + const extra = fallbackPosts.filter(el => !primarySet.has(el)); + const combined = [...primaryPosts, ...extra]; + // Deduplicate nested containers of the same post: same-post ancestors + // share all [dir="auto"] blocks, so joining them gives a stable signature. + // Different posts by the same author differ in body text even if they + // share an author-name prefix, so they won't collide here. + const seenContent = new Set(); + const deduped = combined.filter(el => { + const key = Array.from(el.querySelectorAll('[dir="auto"]')) + .map(s => s.textContent.trim()).filter(t => t.length > 5) + .join('|').substring(0, 200); + if (!key || seenContent.has(key)) return false; + seenContent.add(key); + return true; + }); + return deduped.slice(0, limit).map((el, i) => extractPost(el, i)); + } + + // ── Diagnostic when both paths return nothing ───────────────────────── + const mainEl = document.querySelector('[role="main"]'); + const articleCount = articleNodes.length; + const mainLen = mainEl ? mainEl.textContent.trim().length : 0; + throw new Error( + 'facebook feed: no posts found. ' + + 'article nodes=' + articleCount + ' (all empty text), ' + + 'main textLength=' + mainLen + '. ' + + 'The page may not be fully loaded or Facebook DOM changed again.' + ); })() ` }, ], diff --git a/clis/facebook/feed.test.js b/clis/facebook/feed.test.js new file mode 100644 index 000000000..1ae1f475f --- /dev/null +++ b/clis/facebook/feed.test.js @@ -0,0 +1,25 @@ +/** + * Regression test: evaluate scripts inside template literals must produce + * syntactically valid JavaScript after framework placeholder substitution. + * Catches double-escaping bugs (\d, \s, \n) that typecheck cannot see + * because the code lives inside a string passed to page.evaluate. + */ +import { describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './feed.js'; + +describe('facebook feed evaluate script', () => { + it('produces valid JS after placeholder substitution', () => { + const cmd = getRegistry().get('facebook/feed'); + expect(cmd).toBeDefined(); + + const evaluateStep = cmd.pipeline?.find(step => 'evaluate' in step); + expect(evaluateStep).toBeDefined(); + + // Replace framework placeholders ${{ expr }} with dummy values so + // new Function() can parse the script without substitution support. + const script = evaluateStep.evaluate.replace(/\$\{\{[^}]*\}\}/g, '10'); + + expect(() => new Function(`return (${script})`)).not.toThrow(); + }); +}); From 049244428121c07a55c2635bba6ea05dd14526ea Mon Sep 17 00:00:00 2001 From: Jeff Chen Date: Mon, 11 May 2026 13:28:56 +0800 Subject: [PATCH 03/12] feat(boss): add job-seeker side support for chatlist and chatmsg (#9) Closes #9. Co-Authored-By: Claude Opus 4.7 --- clis/boss/chatlist.js | 90 ++++++++++++++++++--- clis/boss/chatlist.test.js | 151 +++++++++++++++++++++++++++++++++++ clis/boss/chatmsg.js | 114 ++++++++++++++++++++------ clis/boss/chatmsg.test.js | 159 +++++++++++++++++++++++++++++++++++++ clis/boss/utils.js | 120 ++++++++++++++++++++++++++-- 5 files changed, 592 insertions(+), 42 deletions(-) create mode 100644 clis/boss/chatlist.test.js create mode 100644 clis/boss/chatmsg.test.js diff --git a/clis/boss/chatlist.js b/clis/boss/chatlist.js index aaadec883..685d86d00 100644 --- a/clis/boss/chatlist.js +++ b/clis/boss/chatlist.js @@ -1,10 +1,54 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { requirePage, navigateToChat, fetchFriendList } from './utils.js'; +import { + requirePage, navigateToChat, navigateToGeekChat, + fetchFriendList, fetchGeekFriendLabelList, fetchGeekFriendInfoList, + readEncryptSystemId, assertOk, IDENTITY_MISMATCH_CODE, +} from './utils.js'; + +function formatMsgTime(ms) { + if (!ms) return ''; + return new Date(ms).toLocaleString('zh-CN'); +} + +function mapBossRow(f) { + return { + name: f.name || '', + company: '', + job: f.jobName || '', + title: '', + last_msg: f.lastMessageInfo?.text || '', + last_time: f.lastTime || '', + uid: f.encryptUid || '', + security_id: f.securityId || '', + }; +} + +async function buildGeekRows(page, limit) { + const encryptSystemId = await readEncryptSystemId(page); + const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId }); + const friendIds = labelList.map((f) => f.friendId).filter(Boolean); + const enriched = await fetchGeekFriendInfoList(page, friendIds); + const enrichMap = new Map(enriched.map((f) => [String(f.friendId ?? f.uid), f])); + return labelList.slice(0, limit).map((f) => { + const e = enrichMap.get(String(f.friendId)) || {}; + return { + name: e.name || f.name || '', + company: e.brandName || f.brandName || '', + job: e.jobName || f.jobName || '', + title: e.bossTitle || f.bossTitle || '', + last_msg: e.lastMessageInfo?.showText || e.lastMsg || f.lastMsg || '', + last_time: e.lastTime || formatMsgTime(e.lastMessageInfo?.msgTime) || formatMsgTime(f.updateTime) || '', + uid: e.encryptUid || f.encryptFriendId || String(e.uid ?? e.friendId ?? f.friendId ?? ''), + security_id: e.securityId || '', + }; + }); +} + cli({ site: 'boss', name: 'chatlist', access: 'read', - description: 'BOSS直聘查看聊天列表(招聘端)', + description: 'BOSS直聘查看聊天列表(招聘端/求职端)', domain: 'www.zhipin.com', strategy: Strategy.COOKIE, navigateBefore: false, @@ -12,23 +56,43 @@ cli({ args: [ { name: 'page', type: 'int', default: 1, help: 'Page number' }, { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, - { name: 'job-id', default: '0', help: 'Filter by job ID (0=all)' }, + { name: 'job-id', default: '0', help: 'Filter by job ID (0=all, boss side only)' }, + { name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' }, ], - columns: ['name', 'job', 'last_msg', 'last_time', 'uid', 'security_id'], + columns: ['name', 'company', 'job', 'title', 'last_msg', 'last_time', 'uid', 'security_id'], func: async (page, kwargs) => { requirePage(page); + const limit = kwargs.limit || 20; + const side = kwargs.side || 'auto'; + + if (side === 'boss') { + await navigateToChat(page); + const friends = await fetchFriendList(page, { + pageNum: kwargs.page || 1, + jobId: kwargs['job-id'] || '0', + }); + return friends.slice(0, limit).map(mapBossRow); + } + + if (side === 'geek') { + await navigateToGeekChat(page); + return await buildGeekRows(page, limit); + } + + // auto: try recruiter first, fall back to geek on identity mismatch await navigateToChat(page); - const friends = await fetchFriendList(page, { + const bossResult = await fetchFriendList(page, { pageNum: kwargs.page || 1, jobId: kwargs['job-id'] || '0', + allowNonZero: true, }); - return friends.slice(0, kwargs.limit || 20).map((f) => ({ - name: f.name || '', - job: f.jobName || '', - last_msg: f.lastMessageInfo?.text || '', - last_time: f.lastTime || '', - uid: f.encryptUid || '', - security_id: f.securityId || '', - })); + if (Array.isArray(bossResult)) { + return bossResult.slice(0, limit).map(mapBossRow); + } + if (bossResult.code === IDENTITY_MISMATCH_CODE) { + await navigateToGeekChat(page); + return await buildGeekRows(page, limit); + } + assertOk(bossResult); }, }); diff --git a/clis/boss/chatlist.test.js b/clis/boss/chatlist.test.js new file mode 100644 index 000000000..7bf25c690 --- /dev/null +++ b/clis/boss/chatlist.test.js @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './chatlist.js'; + +const BOSS_FRIEND = { + name: '张三', + jobName: '后端工程师', + lastMessageInfo: { text: '你好' }, + lastTime: '2024-01-01 10:00', + encryptUid: 'enc-boss-uid', + securityId: 'boss-sec-id', +}; + +const GEEK_LABEL_FRIEND = { + friendId: 12345, + name: '李四', + brandName: '字节跳动', + jobName: '产品经理', + bossTitle: 'HR', + lastMsg: '感谢投递', + updateTime: 1704067200000, + encryptFriendId: 'enc-geek-uid', +}; + +const GEEK_ENRICHED = { + friendId: 12345, + uid: 99999, + name: '李四', + brandName: '字节跳动', + jobName: '产品经理', + bossTitle: 'HR总监', + encryptUid: 'enc-geek-uid', + securityId: 'geek-sec-id', + lastMessageInfo: { showText: '感谢投递', msgTime: 1704067200000 }, + lastTime: '2024-01-01', +}; + +function createPageMock(evaluateImpl) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(evaluateImpl), + }; +} + +describe('boss chatlist', () => { + const command = getRegistry().get('boss/chatlist'); + + it('--side boss preserves existing behavior with 8-column output', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 0, zpData: { friendList: [BOSS_FRIEND] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' }); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index')); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: '张三', + company: '', + job: '后端工程师', + title: '', + last_msg: '你好', + uid: 'enc-boss-uid', + security_id: 'boss-sec-id', + }); + }); + + it('--side geek maps enriched getGeekFriendList data into 8 columns', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_ENRICHED] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' }); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat')); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: '李四', + company: '字节跳动', + job: '产品经理', + title: 'HR总监', + uid: 'enc-geek-uid', + security_id: 'geek-sec-id', + }); + }); + + it('--side geek falls back to label fields when enrichment has no match', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' }); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('李四'); + expect(rows[0].company).toBe('字节跳动'); + expect(rows[0].security_id).toBe(''); + }); + + it('--side auto falls back to geek when recruiter returns code 24', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 24, message: '请切换身份后再试' }; + } + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_ENRICHED] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' }); + expect(rows).toHaveLength(1); + expect(rows[0].company).toBe('字节跳动'); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat')); + }); + + it('--side auto uses recruiter results when code 0 and does not call geek API', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 0, zpData: { friendList: [BOSS_FRIEND] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' }); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('张三'); + const evaluateCalls = page.evaluate.mock.calls.map((c) => c[0]); + expect(evaluateCalls.some((s) => s.includes('geekFilterByLabel'))).toBe(false); + }); + + it('registers --side as a choices-constrained arg defaulting to auto', () => { + const sideArg = command.args.find((a) => a.name === 'side'); + expect(sideArg?.choices).toEqual(['auto', 'boss', 'geek']); + expect(sideArg?.default).toBe('auto'); + }); +}); diff --git a/clis/boss/chatmsg.js b/clis/boss/chatmsg.js index a9ae945cb..28dcdf2a4 100644 --- a/clis/boss/chatmsg.js +++ b/clis/boss/chatmsg.js @@ -1,10 +1,61 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js'; +import { + requirePage, navigateToChat, navigateToGeekChat, + bossFetch, findFriendByUid, findGeekFriendByUid, + fetchGeekHistoryMsg, readEncryptSystemId, + assertOk, IDENTITY_MISMATCH_CODE, +} from './utils.js'; + +const TYPE_MAP = { + 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统', + 6: '名片', 7: '语音', 8: '视频', 9: '表情', +}; + +function mapBossMsg(m, friend) { + const fromObj = m.from || {}; + const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false; + return { + from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name), + type: TYPE_MAP[m.type] || `其他(${m.type})`, + text: m.text || m.body?.text || '', + time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '', + }; +} + +function mapGeekMsg(m) { + return { + from: m.received ? '对方' : '我', + type: TYPE_MAP[m.type] || `其他(${m.type})`, + text: m.text || m.body?.text || m.body?.content || m.body?.showText || + JSON.stringify(m.body || {}).slice(0, 120), + time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '', + }; +} + +async function bossChatMsg(page, kwargs) { + const friend = await findFriendByUid(page, kwargs.uid); + if (!friend) throw new Error('未找到该候选人'); + const gid = friend.uid; + const securityId = encodeURIComponent(friend.securityId); + const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`; + const msgData = await bossFetch(page, msgUrl); + const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || []; + return messages.map((m) => mapBossMsg(m, friend)); +} + +async function geekChatMsg(page, kwargs, encryptSystemId) { + const friend = await findGeekFriendByUid(page, kwargs.uid, { encryptSystemId }); + if (!friend) throw new Error('未找到该聊天(geek 侧)'); + if (!friend.securityId) throw new Error('该聊天缺少 securityId,无法获取历史消息'); + const messages = await fetchGeekHistoryMsg(page, friend, { page: kwargs.page }); + return messages.map(mapGeekMsg); +} + cli({ site: 'boss', name: 'chatmsg', access: 'read', - description: 'BOSS直聘查看与候选人的聊天消息', + description: 'BOSS直聘查看聊天消息历史(招聘端/求职端)', domain: 'www.zhipin.com', strategy: Strategy.COOKIE, navigateBefore: false, @@ -12,32 +63,47 @@ cli({ args: [ { name: 'uid', required: true, positional: true, help: 'Encrypted UID (from chatlist)' }, { name: 'page', type: 'int', default: 1, help: 'Page number' }, + { name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' }, ], columns: ['from', 'type', 'text', 'time'], func: async (page, kwargs) => { requirePage(page); + const side = kwargs.side || 'auto'; + + if (side === 'boss') { + await navigateToChat(page); + return await bossChatMsg(page, kwargs); + } + + if (side === 'geek') { + await navigateToGeekChat(page); + const encryptSystemId = await readEncryptSystemId(page); + return await geekChatMsg(page, kwargs, encryptSystemId); + } + + // auto: try recruiter first, fall back to geek when not found or identity mismatch await navigateToChat(page); - const friend = await findFriendByUid(page, kwargs.uid); - if (!friend) - throw new Error('未找到该候选人'); - const gid = friend.uid; - const securityId = encodeURIComponent(friend.securityId); - const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`; - const msgData = await bossFetch(page, msgUrl); - const TYPE_MAP = { - 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统', - 6: '名片', 7: '语音', 8: '视频', 9: '表情', - }; - const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || []; - return messages.map((m) => { - const fromObj = m.from || {}; - const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false; - return { - from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name), - type: TYPE_MAP[m.type] || '其他(' + m.type + ')', - text: m.text || m.body?.text || '', - time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '', - }; - }); + const bossResult = await findFriendByUid(page, kwargs.uid, { allowNonZero: true }); + if (bossResult?.friend) { + const friend = bossResult.friend; + const gid = friend.uid; + const securityId = encodeURIComponent(friend.securityId); + const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`; + const msgData = await bossFetch(page, msgUrl); + const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || []; + return messages.map((m) => mapBossMsg(m, friend)); + } + // Not found in recruiter list or identity mismatch — check for hard errors first + if (bossResult?.code && bossResult.code !== 0 && bossResult.code !== IDENTITY_MISMATCH_CODE) { + assertOk(bossResult); + } + // Fall back to geek side + await navigateToGeekChat(page); + const encryptSystemId = await readEncryptSystemId(page); + const geekFriend = await findGeekFriendByUid(page, kwargs.uid, { encryptSystemId }); + if (!geekFriend) throw new Error('uid 在招聘端与求职端聊天列表中均未找到'); + if (!geekFriend.securityId) throw new Error('该聊天缺少 securityId,无法获取历史消息'); + const messages = await fetchGeekHistoryMsg(page, geekFriend, { page: kwargs.page }); + return messages.map(mapGeekMsg); }, }); diff --git a/clis/boss/chatmsg.test.js b/clis/boss/chatmsg.test.js new file mode 100644 index 000000000..99c70bacc --- /dev/null +++ b/clis/boss/chatmsg.test.js @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './chatmsg.js'; + +const BOSS_FRIEND = { + uid: 12345, + encryptUid: 'enc-boss-uid', + securityId: 'boss-sec-id', + name: '候选人甲', +}; +const BOSS_MSGS = [ + { type: 1, text: 'Hello', from: { uid: 99999, name: 'HR' }, time: 1704067200000 }, + { type: 1, text: '感谢', from: { uid: 12345, name: '候选人甲' }, time: 1704067201000 }, +]; + +const GEEK_FRIEND_LABEL = { + friendId: 11111, + encryptFriendId: 'enc-geek-uid', + name: 'Boss张', + brandName: '公司A', +}; +const GEEK_FRIEND_ENRICHED = { + friendId: 11111, + uid: 67890, + encryptUid: 'enc-geek-uid', + securityId: 'geek-sec-id', + name: 'Boss张', +}; +const GEEK_MSGS = [ + { type: 1, text: '欢迎投递', received: true, time: 1704067200000 }, + { type: 1, text: '谢谢', received: false, time: 1704067201000 }, +]; + +function createPageMock(evaluateImpl) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(evaluateImpl), + }; +} + +describe('boss chatmsg', () => { + const command = getRegistry().get('boss/chatmsg'); + + it('--side boss preserves existing behavior', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 0, zpData: { friendList: [BOSS_FRIEND] } }; + } + if (script.includes('boss/historyMsg')) { + return { code: 0, zpData: { messages: BOSS_MSGS } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' }); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index')); + expect(rows).toHaveLength(2); + expect(rows[0].from).toBe('我'); + expect(rows[1].from).toBe('候选人甲'); + }); + + it('--side geek calls historyMsg with bossId, securityId, page, c=20, src=0', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: GEEK_MSGS } }; + } + return {}; + }); + await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' }); + const historyScript = page.evaluate.mock.calls.find((c) => c[0].includes('geek/historyMsg'))?.[0]; + expect(historyScript).toBeDefined(); + expect(historyScript).toContain('bossId=67890'); + expect(historyScript).toContain('securityId='); + expect(historyScript).toContain('page=1'); + expect(historyScript).toContain('c=20'); + expect(historyScript).toContain('src=0'); + }); + + it('--side geek maps received:true to 对方 and received:false to 我', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: GEEK_MSGS } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' }); + expect(rows[0].from).toBe('对方'); + expect(rows[1].from).toBe('我'); + }); + + it('non-text message body does not crash and produces truncated JSON', async () => { + const nonTextMsg = { type: 99, received: true, time: 1704067200000, body: { action: 'resume_request', detail: 'X' } }; + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: [nonTextMsg] } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' }); + expect(rows).toHaveLength(1); + expect(rows[0].text).toContain('resume_request'); + }); + + it('--side auto falls back to geek when recruiter returns code 24', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 24, message: '请切换身份后再试' }; + } + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: GEEK_MSGS } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'auto' }); + expect(rows).toHaveLength(2); + expect(rows[0].from).toBe('对方'); + }); + + it('--side geek throws when uid is not found in geek chat list', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [] } }; + } + return {}; + }); + await expect( + command.func(page, { uid: 'unknown-uid', page: 1, side: 'geek' }) + ).rejects.toThrow('未找到该聊天(geek 侧)'); + }); +}); diff --git a/clis/boss/utils.js b/clis/boss/utils.js index 92404ac9c..46cde06bc 100644 --- a/clis/boss/utils.js +++ b/clis/boss/utils.js @@ -95,7 +95,8 @@ export async function fetchFriendList(page, opts = {}) { const pageNum = opts.pageNum ?? 1; const jobId = opts.jobId ?? '0'; const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`; - const data = await bossFetch(page, url); + const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero }); + if (opts.allowNonZero && data.code !== 0) return data; return data.zpData?.friendList || []; } /** @@ -115,10 +116,14 @@ export async function findFriendByUid(page, encryptUid, opts = {}) { const checkGreetList = opts.checkGreetList ?? false; // Search friend list pages for (let p = 1; p <= maxPages; p++) { - const friends = await fetchFriendList(page, { pageNum: p }); + const result = await fetchFriendList(page, { pageNum: p, allowNonZero: opts.allowNonZero }); + if (opts.allowNonZero && !Array.isArray(result)) { + return { friend: null, code: result.code }; + } + const friends = Array.isArray(result) ? result : []; const found = friends.find((f) => f.encryptUid === encryptUid); if (found) - return found; + return opts.allowNonZero ? { friend: found, code: 0 } : found; if (friends.length === 0) break; } @@ -127,9 +132,9 @@ export async function findFriendByUid(page, encryptUid, opts = {}) { const greetList = await fetchRecommendList(page); const found = greetList.find((f) => f.encryptUid === encryptUid); if (found) - return found; + return opts.allowNonZero ? { friend: found, code: 0 } : found; } - return null; + return opts.allowNonZero ? { friend: null, code: 0 } : null; } // ── UI automation helpers ─────────────────────────────────────────────────── /** @@ -221,3 +226,108 @@ export function verbose(msg) { console.error(`[opencli:boss] ${msg}`); } } +// ── Geek-side helpers ──────────────────────────────────────────────────────── +export const IDENTITY_MISMATCH_CODE = 24; +const GEEK_CHAT_URL = `https://${BOSS_DOMAIN}/web/geek/chat`; +/** + * Navigate to the job-seeker chat page. + * Establishes the cookie + JS-global context needed for geek-side API calls. + */ +export async function navigateToGeekChat(page, waitSeconds = 2) { + await page.goto(GEEK_CHAT_URL); + await page.wait({ time: waitSeconds }); +} +/** + * Read the encryptSystemId value required by the geek-side list API. + * Tries document.cookie, then window globals, then localStorage. + * Throws if nothing is found — caller must have navigated to the geek chat page first. + */ +export async function readEncryptSystemId(page) { + const result = await page.evaluate(` + (() => { + try { + const m = document.cookie.match(/encryptSystemId=([^;]+)/i); + if (m) return decodeURIComponent(m[1]); + } catch (_) {} + const sources = [window.__NUXT__, window.__INITIAL_STATE__]; + for (const s of sources) { + if (!s) continue; + try { + const flat = JSON.stringify(s); + const m = flat.match(/"encryptSystemId":"([^"]+)"/); + if (m) return m[1]; + } catch (_) {} + } + try { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.toLowerCase().includes('encryptsystemid')) { + const v = localStorage.getItem(k); + if (v) return v; + } + } + } catch (_) {} + return ''; + })() + `); + if (!result) { + throw new Error('geek 聊天页未找到 encryptSystemId(请确认已登录求职端)'); + } + return result; +} +/** + * Fetch the job-seeker chat list (brief info, no securityId). + * Use fetchGeekFriendInfoList to enrich with securityId before calling chatmsg. + */ +export async function fetchGeekFriendLabelList(page, opts = {}) { + const labelId = opts.labelId ?? 0; + const encryptSystemId = opts.encryptSystemId ?? ''; + const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/geekFilterByLabel?labelId=${labelId}&encryptSystemId=${encodeURIComponent(encryptSystemId)}`; + const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero }); + if (opts.allowNonZero && data.code !== 0) return data; + return data.zpData?.friendList || []; +} +/** + * Enrich a batch of geek friends with full fields including securityId. + * Processes in batches of 50 to avoid oversized request bodies. + */ +export async function fetchGeekFriendInfoList(page, friendIds = []) { + if (!friendIds.length) return []; + const BATCH_SIZE = 50; + const results = []; + for (let i = 0; i < friendIds.length; i += BATCH_SIZE) { + const batch = friendIds.slice(i, i + BATCH_SIZE).map(String); + const body = `friendIds=${batch.join(',')}`; + const data = await bossFetch(page, `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getGeekFriendList.json`, { + method: 'POST', + body, + }); + results.push(...(data.zpData?.result || [])); + } + return results; +} +/** + * Find a geek-side friend by encrypted uid. + * Merges label-list and enriched data; returns null if not found. + */ +export async function findGeekFriendByUid(page, encryptUid, opts = {}) { + const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId: opts.encryptSystemId }); + const candidate = labelList.find((f) => f.encryptFriendId === encryptUid || + String(f.uid) === String(encryptUid) || + String(f.friendId) === String(encryptUid)); + if (!candidate) return null; + const enriched = await fetchGeekFriendInfoList(page, [candidate.friendId]); + return { ...candidate, ...(enriched[0] || {}) }; +} +/** + * Fetch message history for a geek-side chat. + * friend must have .uid (boss's numeric id) and .securityId. + */ +export async function fetchGeekHistoryMsg(page, friend, opts = {}) { + const pageNum = opts.page ?? 1; + const bossId = friend.uid; + const securityId = encodeURIComponent(friend.securityId || ''); + const url = `https://${BOSS_DOMAIN}/wapi/zpchat/geek/historyMsg?bossId=${bossId}&securityId=${securityId}&page=${pageNum}&c=20&src=0`; + const data = await bossFetch(page, url); + return data.zpData?.messages || []; +} From 14552001e93f6771b92ed49bcde23bf494cf23e0 Mon Sep 17 00:00:00 2001 From: Jeff Chen Date: Mon, 11 May 2026 13:47:10 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(boss):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20manifest,=20encryptSystemId,=20enrich=20slice,=20de?= =?UTF-8?q?dup=20auto=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Regenerate cli-manifest.json so --side is recognized by the CLI - readEncryptSystemId: expand to script tags, broader window scan, localStorage values; return '' instead of throwing so API can surface its own error - chatlist: slice labelList before fetchGeekFriendInfoList to skip unnecessary batch calls - chatmsg: refactor bossChatMsg to accept optional friend, eliminating duplication in auto path Co-Authored-By: Claude Opus 4.7 --- cli-manifest.json | 32 ++++++++++++++++++++--- clis/boss/chatlist.js | 5 ++-- clis/boss/chatmsg.js | 14 +++------- clis/boss/utils.js | 59 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 84 insertions(+), 26 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index b066e92c7..721ce65eb 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -3484,7 +3484,7 @@ { "site": "boss", "name": "chatlist", - "description": "BOSS直聘查看聊天列表(招聘端)", + "description": "BOSS直聘查看聊天列表(招聘端/求职端)", "access": "read", "domain": "www.zhipin.com", "strategy": "cookie", @@ -3509,12 +3509,26 @@ "type": "str", "default": "0", "required": false, - "help": "Filter by job ID (0=all)" + "help": "Filter by job ID (0=all, boss side only)" + }, + { + "name": "side", + "type": "str", + "default": "auto", + "required": false, + "help": "Identity side: auto (default), boss (recruiter), or geek (job-seeker)", + "choices": [ + "auto", + "boss", + "geek" + ] } ], "columns": [ "name", + "company", "job", + "title", "last_msg", "last_time", "uid", @@ -3528,7 +3542,7 @@ { "site": "boss", "name": "chatmsg", - "description": "BOSS直聘查看与候选人的聊天消息", + "description": "BOSS直聘查看聊天消息历史(招聘端/求职端)", "access": "read", "domain": "www.zhipin.com", "strategy": "cookie", @@ -3547,6 +3561,18 @@ "default": 1, "required": false, "help": "Page number" + }, + { + "name": "side", + "type": "str", + "default": "auto", + "required": false, + "help": "Identity side: auto (default), boss (recruiter), or geek (job-seeker)", + "choices": [ + "auto", + "boss", + "geek" + ] } ], "columns": [ diff --git a/clis/boss/chatlist.js b/clis/boss/chatlist.js index 685d86d00..a821aadba 100644 --- a/clis/boss/chatlist.js +++ b/clis/boss/chatlist.js @@ -26,10 +26,11 @@ function mapBossRow(f) { async function buildGeekRows(page, limit) { const encryptSystemId = await readEncryptSystemId(page); const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId }); - const friendIds = labelList.map((f) => f.friendId).filter(Boolean); + const slicedLabels = labelList.slice(0, limit); + const friendIds = slicedLabels.map((f) => f.friendId).filter(Boolean); const enriched = await fetchGeekFriendInfoList(page, friendIds); const enrichMap = new Map(enriched.map((f) => [String(f.friendId ?? f.uid), f])); - return labelList.slice(0, limit).map((f) => { + return slicedLabels.map((f) => { const e = enrichMap.get(String(f.friendId)) || {}; return { name: e.name || f.name || '', diff --git a/clis/boss/chatmsg.js b/clis/boss/chatmsg.js index 28dcdf2a4..e9c97cc4c 100644 --- a/clis/boss/chatmsg.js +++ b/clis/boss/chatmsg.js @@ -32,8 +32,8 @@ function mapGeekMsg(m) { }; } -async function bossChatMsg(page, kwargs) { - const friend = await findFriendByUid(page, kwargs.uid); +async function bossChatMsg(page, kwargs, existingFriend) { + const friend = existingFriend ?? await findFriendByUid(page, kwargs.uid); if (!friend) throw new Error('未找到该候选人'); const gid = friend.uid; const securityId = encodeURIComponent(friend.securityId); @@ -85,15 +85,9 @@ cli({ await navigateToChat(page); const bossResult = await findFriendByUid(page, kwargs.uid, { allowNonZero: true }); if (bossResult?.friend) { - const friend = bossResult.friend; - const gid = friend.uid; - const securityId = encodeURIComponent(friend.securityId); - const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`; - const msgData = await bossFetch(page, msgUrl); - const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || []; - return messages.map((m) => mapBossMsg(m, friend)); + return await bossChatMsg(page, kwargs, bossResult.friend); } - // Not found in recruiter list or identity mismatch — check for hard errors first + // Not found or identity mismatch — check for hard errors before falling back if (bossResult?.code && bossResult.code !== 0 && bossResult.code !== IDENTITY_MISMATCH_CODE) { assertOk(bossResult); } diff --git a/clis/boss/utils.js b/clis/boss/utils.js index 46cde06bc..1eb1ffdb5 100644 --- a/clis/boss/utils.js +++ b/clis/boss/utils.js @@ -239,41 +239,78 @@ export async function navigateToGeekChat(page, waitSeconds = 2) { } /** * Read the encryptSystemId value required by the geek-side list API. - * Tries document.cookie, then window globals, then localStorage. - * Throws if nothing is found — caller must have navigated to the geek chat page first. + * Tries cookie, inline script tags, known window globals, a full window scan, + * and localStorage. Returns empty string if not found — the API may still + * succeed without it, and any server-side error will surface a clearer message. + * Caller must have navigated to the geek chat page first. */ export async function readEncryptSystemId(page) { const result = await page.evaluate(` (() => { + // 1. cookie try { const m = document.cookie.match(/encryptSystemId=([^;]+)/i); if (m) return decodeURIComponent(m[1]); } catch (_) {} - const sources = [window.__NUXT__, window.__INITIAL_STATE__]; - for (const s of sources) { - if (!s) continue; + // 2. inline