utils.js
1 // ── Constants ─────────────────────────────────────────────────────────────── 2 const BOSS_DOMAIN = 'www.zhipin.com'; 3 const CHAT_URL = `https://${BOSS_DOMAIN}/web/chat/index`; 4 const COOKIE_EXPIRED_CODES = new Set([7, 37]); 5 const COOKIE_EXPIRED_MSG = 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。'; 6 const DEFAULT_TIMEOUT = 15_000; 7 // ── Core helpers ──────────────────────────────────────────────────────────── 8 /** 9 * Assert that page is available (non-null). 10 */ 11 export function requirePage(page) { 12 if (!page) 13 throw new Error('Browser page required'); 14 } 15 /** 16 * Navigate to BOSS chat page and wait for it to settle. 17 * This establishes the cookie context needed for subsequent API calls. 18 */ 19 export async function navigateToChat(page, waitSeconds = 2) { 20 await page.goto(CHAT_URL); 21 await page.wait({ time: waitSeconds }); 22 } 23 /** 24 * Navigate to a custom BOSS page (for search/detail that use different pages). 25 */ 26 export async function navigateTo(page, url, waitSeconds = 1) { 27 await page.goto(url); 28 await page.wait({ time: waitSeconds }); 29 } 30 /** 31 * Check if an API response indicates cookie expiry and throw a clear error. 32 * Call this after every BOSS API response with a non-zero code. 33 */ 34 export function checkAuth(data) { 35 if (COOKIE_EXPIRED_CODES.has(data.code)) { 36 throw new Error(COOKIE_EXPIRED_MSG); 37 } 38 } 39 /** 40 * Throw if the API response is not code 0. 41 * Checks for cookie expiry first, then throws with the provided message. 42 */ 43 export function assertOk(data, errorPrefix) { 44 if (data.code === 0) 45 return; 46 checkAuth(data); 47 const prefix = errorPrefix ? `${errorPrefix}: ` : ''; 48 throw new Error(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`); 49 } 50 /** 51 * Make a credentialed XHR request via page.evaluate(). 52 * 53 * This is the single XHR template — no more copy-pasting the same 15-line 54 * XMLHttpRequest boilerplate across every adapter. 55 * 56 * @returns Parsed JSON response 57 * @throws On network error, timeout, JSON parse failure, or cookie expiry 58 */ 59 export async function bossFetch(page, url, opts = {}) { 60 const method = opts.method ?? 'GET'; 61 const timeout = opts.timeout ?? DEFAULT_TIMEOUT; 62 const body = opts.body ?? null; 63 // Build the evaluate script. We use JSON.stringify for safe interpolation. 64 const script = ` 65 async () => { 66 return new Promise((resolve, reject) => { 67 const xhr = new XMLHttpRequest(); 68 xhr.open(${JSON.stringify(method)}, ${JSON.stringify(url)}, true); 69 xhr.withCredentials = true; 70 xhr.timeout = ${timeout}; 71 xhr.setRequestHeader('Accept', 'application/json'); 72 ${method === 'POST' ? `xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');` : ''} 73 xhr.onload = () => { 74 try { resolve(JSON.parse(xhr.responseText)); } 75 catch(e) { reject(new Error('JSON parse failed: ' + xhr.responseText.substring(0, 200))); } 76 }; 77 xhr.onerror = () => reject(new Error('Network Error')); 78 xhr.ontimeout = () => reject(new Error('Timeout')); 79 xhr.send(${body ? JSON.stringify(body) : 'null'}); 80 }); 81 } 82 `; 83 const data = await page.evaluate(script); 84 // Auto-check auth unless caller opts out 85 if (!opts.allowNonZero && data.code !== 0) { 86 assertOk(data); 87 } 88 return data; 89 } 90 // ── Convenience helpers ───────────────────────────────────────────────────── 91 /** 92 * Fetch the boss friend (chat) list. 93 */ 94 export async function fetchFriendList(page, opts = {}) { 95 const pageNum = opts.pageNum ?? 1; 96 const jobId = opts.jobId ?? '0'; 97 const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`; 98 const data = await bossFetch(page, url); 99 return data.zpData?.friendList || []; 100 } 101 /** 102 * Fetch the recommended candidates (greetRecSortList). 103 */ 104 export async function fetchRecommendList(page) { 105 const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/greetRecSortList`; 106 const data = await bossFetch(page, url); 107 return data.zpData?.friendList || []; 108 } 109 /** 110 * Find a friend by encryptUid, searching through friend list and optionally greet list. 111 * Returns null if not found. 112 */ 113 export async function findFriendByUid(page, encryptUid, opts = {}) { 114 const maxPages = opts.maxPages ?? 1; 115 const checkGreetList = opts.checkGreetList ?? false; 116 // Search friend list pages 117 for (let p = 1; p <= maxPages; p++) { 118 const friends = await fetchFriendList(page, { pageNum: p }); 119 const found = friends.find((f) => f.encryptUid === encryptUid); 120 if (found) 121 return found; 122 if (friends.length === 0) 123 break; 124 } 125 // Optionally check greet list 126 if (checkGreetList) { 127 const greetList = await fetchRecommendList(page); 128 const found = greetList.find((f) => f.encryptUid === encryptUid); 129 if (found) 130 return found; 131 } 132 return null; 133 } 134 // ── UI automation helpers ─────────────────────────────────────────────────── 135 /** 136 * Click on a candidate in the chat list by their numeric UID. 137 * @returns true if clicked, false if not found 138 */ 139 export async function clickCandidateInList(page, numericUid) { 140 const uid = String(numericUid).replace(/[^0-9]/g, ''); // sanitize to digits only 141 const result = await page.evaluate(` 142 async () => { 143 const uid = ${JSON.stringify(uid)}; 144 const item = document.querySelector('#_' + uid + '-0') || document.querySelector('[id^="_' + uid + '"]'); 145 if (item) { 146 item.click(); 147 return { clicked: true }; 148 } 149 const items = document.querySelectorAll('.geek-item'); 150 for (const el of items) { 151 if (el.id && el.id.startsWith('_' + uid)) { 152 el.click(); 153 return { clicked: true }; 154 } 155 } 156 return { clicked: false }; 157 } 158 `); 159 return result.clicked; 160 } 161 /** 162 * Type a message into the chat editor and send it. 163 * @returns true if sent successfully 164 */ 165 export async function typeAndSendMessage(page, text) { 166 const typed = await page.evaluate(` 167 async () => { 168 const selectors = [ 169 '.chat-editor [contenteditable="true"]', 170 '.chat-input [contenteditable="true"]', 171 '.message-editor [contenteditable="true"]', 172 '.chat-conversation [contenteditable="true"]', 173 '[contenteditable="true"]', 174 'textarea', 175 ]; 176 for (const sel of selectors) { 177 const el = document.querySelector(sel); 178 if (el && el.offsetParent !== null) { 179 el.focus(); 180 if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { 181 el.value = ${JSON.stringify(text)}; 182 el.dispatchEvent(new Event('input', { bubbles: true })); 183 } else { 184 el.textContent = ''; 185 el.focus(); 186 document.execCommand('insertText', false, ${JSON.stringify(text)}); 187 el.dispatchEvent(new Event('input', { bubbles: true })); 188 } 189 return { found: true }; 190 } 191 } 192 return { found: false }; 193 } 194 `); 195 if (!typed.found) 196 return false; 197 await page.wait({ time: 0.5 }); 198 // Click send button 199 const sent = await page.evaluate(` 200 async () => { 201 const btn = document.querySelector('.conversation-editor .submit') 202 || document.querySelector('.submit-content .submit') 203 || document.querySelector('.conversation-operate .submit'); 204 if (btn) { 205 btn.click(); 206 return { clicked: true }; 207 } 208 return { clicked: false }; 209 } 210 `); 211 if (!sent.clicked) { 212 await page.pressKey('Enter'); 213 } 214 return true; 215 } 216 /** 217 * Verbose log helper — prints when OPENCLI_VERBOSE is set. 218 */ 219 export function verbose(msg) { 220 if (process.env.OPENCLI_VERBOSE) { 221 console.error(`[opencli:boss] ${msg}`); 222 } 223 }