/ clis / boss / utils.js
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  }