mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
80eef46b4e
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation Separates CLI adapters from the core runtime to prepare for independent adapter distribution via postinstall fetch. Key changes: - Move src/clis/ → clis/ (adapters at repo root) - Change tsconfig rootDir from "src" to "." so tsc compiles both - Create root-level shim files (registry.ts, errors.ts, etc.) so adapter relative imports (../../registry.js) resolve correctly - Update build-manifest.ts, main.ts paths for new dist/src/ structure - Expand ensureUserCliCompatShims() to cover all adapter import targets (types, utils, logger, launcher, browser/*, download/*, pipeline/*) - Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync - Update vitest.config.ts adapter test paths - Add package.json files field to exclude adapters from npm package Official adapter files are unconditionally overwritten on update; user-created files not in the manifest are preserved. * fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters - Add dist/clis/ and dist/cli-manifest.json to package.json files field so built-in adapters and manifest ship with the npm package - Replace execSync with execFileSync to prevent command injection - Add version check to skip redundant adapter fetches - Track tmpRoot explicitly for reliable cleanup * fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback 1. Manifest-based update strategy: - Read old manifest to identify previously-official files - Clean up files removed upstream (in old manifest but not new) - User-created files (never in any manifest) remain untouched 2. Only run fetch-adapters on global install (npm_config_global=true) or explicit OPENCLI_FETCH=1, preventing heavy side effects for local/dev installs 3. First-run fallback in discovery.ts: - ensureUserAdapters() checks for adapter-manifest.json - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js - Guarantees adapters are available even with --ignore-scripts * fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead * feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger * simplify: replace git clone with local copy from dist/clis/ Adapters already ship in the npm package (dist/clis/), so there's no need to clone from GitHub. Copy directly from the installed package: - Eliminates git, curl, tar dependencies - No network calls in postinstall - No timeout/offline issues - Version always matches the installed CLI - ~65 lines of clone/download code replaced by one cpSync loop
288 lines
9.6 KiB
TypeScript
288 lines
9.6 KiB
TypeScript
/**
|
|
* BOSS直聘 common utilities — shared logic for all boss adapters.
|
|
*
|
|
* Consolidates:
|
|
* - Page navigation with cookie context
|
|
* - XHR-based API calls (GET/POST) with automatic login state detection
|
|
* - Cookie expiry error codes (code 7, 37)
|
|
* - Verbose logging
|
|
*/
|
|
import type { IPage } from '../../types.js';
|
|
|
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
|
|
const BOSS_DOMAIN = 'www.zhipin.com';
|
|
const CHAT_URL = `https://${BOSS_DOMAIN}/web/chat/index`;
|
|
const COOKIE_EXPIRED_CODES = new Set([7, 37]);
|
|
const COOKIE_EXPIRED_MSG = 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。';
|
|
const DEFAULT_TIMEOUT = 15_000;
|
|
|
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface BossApiResponse {
|
|
code: number;
|
|
message?: string;
|
|
zpData?: any;
|
|
[key: string]: any;
|
|
}
|
|
|
|
export interface FetchOptions {
|
|
/** HTTP method, defaults to 'GET' */
|
|
method?: 'GET' | 'POST';
|
|
/** POST body (will be sent as application/x-www-form-urlencoded) */
|
|
body?: string;
|
|
/** XHR timeout in ms, defaults to 15000 */
|
|
timeout?: number;
|
|
/** If true, don't throw on non-zero code — return the raw response */
|
|
allowNonZero?: boolean;
|
|
}
|
|
|
|
// ── Core helpers ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Assert that page is available (non-null).
|
|
*/
|
|
export function requirePage(page: IPage | null): asserts page is IPage {
|
|
if (!page) throw new Error('Browser page required');
|
|
}
|
|
|
|
/**
|
|
* Navigate to BOSS chat page and wait for it to settle.
|
|
* This establishes the cookie context needed for subsequent API calls.
|
|
*/
|
|
export async function navigateToChat(page: IPage, waitSeconds = 2): Promise<void> {
|
|
await page.goto(CHAT_URL);
|
|
await page.wait({ time: waitSeconds });
|
|
}
|
|
|
|
/**
|
|
* Navigate to a custom BOSS page (for search/detail that use different pages).
|
|
*/
|
|
export async function navigateTo(page: IPage, url: string, waitSeconds = 1): Promise<void> {
|
|
await page.goto(url);
|
|
await page.wait({ time: waitSeconds });
|
|
}
|
|
|
|
/**
|
|
* Check if an API response indicates cookie expiry and throw a clear error.
|
|
* Call this after every BOSS API response with a non-zero code.
|
|
*/
|
|
export function checkAuth(data: BossApiResponse): void {
|
|
if (COOKIE_EXPIRED_CODES.has(data.code)) {
|
|
throw new Error(COOKIE_EXPIRED_MSG);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Throw if the API response is not code 0.
|
|
* Checks for cookie expiry first, then throws with the provided message.
|
|
*/
|
|
export function assertOk(data: BossApiResponse, errorPrefix?: string): void {
|
|
if (data.code === 0) return;
|
|
checkAuth(data);
|
|
const prefix = errorPrefix ? `${errorPrefix}: ` : '';
|
|
throw new Error(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
|
|
}
|
|
|
|
/**
|
|
* Make a credentialed XHR request via page.evaluate().
|
|
*
|
|
* This is the single XHR template — no more copy-pasting the same 15-line
|
|
* XMLHttpRequest boilerplate across every adapter.
|
|
*
|
|
* @returns Parsed JSON response
|
|
* @throws On network error, timeout, JSON parse failure, or cookie expiry
|
|
*/
|
|
export async function bossFetch(
|
|
page: IPage,
|
|
url: string,
|
|
opts: FetchOptions = {},
|
|
): Promise<BossApiResponse> {
|
|
const method = opts.method ?? 'GET';
|
|
const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
|
|
const body = opts.body ?? null;
|
|
|
|
// Build the evaluate script. We use JSON.stringify for safe interpolation.
|
|
const script = `
|
|
async () => {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open(${JSON.stringify(method)}, ${JSON.stringify(url)}, true);
|
|
xhr.withCredentials = true;
|
|
xhr.timeout = ${timeout};
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
${method === 'POST' ? `xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');` : ''}
|
|
xhr.onload = () => {
|
|
try { resolve(JSON.parse(xhr.responseText)); }
|
|
catch(e) { reject(new Error('JSON parse failed: ' + xhr.responseText.substring(0, 200))); }
|
|
};
|
|
xhr.onerror = () => reject(new Error('Network Error'));
|
|
xhr.ontimeout = () => reject(new Error('Timeout'));
|
|
xhr.send(${body ? JSON.stringify(body) : 'null'});
|
|
});
|
|
}
|
|
`;
|
|
|
|
const data = await page.evaluate(script) as BossApiResponse;
|
|
|
|
// Auto-check auth unless caller opts out
|
|
if (!opts.allowNonZero && data.code !== 0) {
|
|
assertOk(data);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// ── Convenience helpers ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch the boss friend (chat) list.
|
|
*/
|
|
export async function fetchFriendList(
|
|
page: IPage,
|
|
opts: { pageNum?: number; jobId?: string } = {},
|
|
): Promise<any[]> {
|
|
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);
|
|
return data.zpData?.friendList || [];
|
|
}
|
|
|
|
/**
|
|
* Fetch the recommended candidates (greetRecSortList).
|
|
*/
|
|
export async function fetchRecommendList(page: IPage): Promise<any[]> {
|
|
const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/greetRecSortList`;
|
|
const data = await bossFetch(page, url);
|
|
return data.zpData?.friendList || [];
|
|
}
|
|
|
|
/**
|
|
* Find a friend by encryptUid, searching through friend list and optionally greet list.
|
|
* Returns null if not found.
|
|
*/
|
|
export async function findFriendByUid(
|
|
page: IPage,
|
|
encryptUid: string,
|
|
opts: { maxPages?: number; checkGreetList?: boolean } = {},
|
|
): Promise<any | null> {
|
|
const maxPages = opts.maxPages ?? 1;
|
|
const checkGreetList = opts.checkGreetList ?? false;
|
|
|
|
// Search friend list pages
|
|
for (let p = 1; p <= maxPages; p++) {
|
|
const friends = await fetchFriendList(page, { pageNum: p });
|
|
const found = friends.find((f: any) => f.encryptUid === encryptUid);
|
|
if (found) return found;
|
|
if (friends.length === 0) break;
|
|
}
|
|
|
|
// Optionally check greet list
|
|
if (checkGreetList) {
|
|
const greetList = await fetchRecommendList(page);
|
|
const found = greetList.find((f: any) => f.encryptUid === encryptUid);
|
|
if (found) return found;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── UI automation helpers ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Click on a candidate in the chat list by their numeric UID.
|
|
* @returns true if clicked, false if not found
|
|
*/
|
|
export async function clickCandidateInList(page: IPage, numericUid: string | number): Promise<boolean> {
|
|
const uid = String(numericUid).replace(/[^0-9]/g, ''); // sanitize to digits only
|
|
const result: any = await page.evaluate(`
|
|
async () => {
|
|
const uid = ${JSON.stringify(uid)};
|
|
const item = document.querySelector('#_' + uid + '-0') || document.querySelector('[id^="_' + uid + '"]');
|
|
if (item) {
|
|
item.click();
|
|
return { clicked: true };
|
|
}
|
|
const items = document.querySelectorAll('.geek-item');
|
|
for (const el of items) {
|
|
if (el.id && el.id.startsWith('_' + uid)) {
|
|
el.click();
|
|
return { clicked: true };
|
|
}
|
|
}
|
|
return { clicked: false };
|
|
}
|
|
`);
|
|
return result.clicked;
|
|
}
|
|
|
|
/**
|
|
* Type a message into the chat editor and send it.
|
|
* @returns true if sent successfully
|
|
*/
|
|
export async function typeAndSendMessage(page: IPage, text: string): Promise<boolean> {
|
|
const typed: any = await page.evaluate(`
|
|
async () => {
|
|
const selectors = [
|
|
'.chat-editor [contenteditable="true"]',
|
|
'.chat-input [contenteditable="true"]',
|
|
'.message-editor [contenteditable="true"]',
|
|
'.chat-conversation [contenteditable="true"]',
|
|
'[contenteditable="true"]',
|
|
'textarea',
|
|
];
|
|
for (const sel of selectors) {
|
|
const el = document.querySelector(sel);
|
|
if (el && el.offsetParent !== null) {
|
|
el.focus();
|
|
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
el.value = ${JSON.stringify(text)};
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
} else {
|
|
el.textContent = '';
|
|
el.focus();
|
|
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
return { found: true };
|
|
}
|
|
}
|
|
return { found: false };
|
|
}
|
|
`);
|
|
|
|
if (!typed.found) return false;
|
|
|
|
await page.wait({ time: 0.5 });
|
|
|
|
// Click send button
|
|
const sent: any = await page.evaluate(`
|
|
async () => {
|
|
const btn = document.querySelector('.conversation-editor .submit')
|
|
|| document.querySelector('.submit-content .submit')
|
|
|| document.querySelector('.conversation-operate .submit');
|
|
if (btn) {
|
|
btn.click();
|
|
return { clicked: true };
|
|
}
|
|
return { clicked: false };
|
|
}
|
|
`);
|
|
|
|
if (!sent.clicked) {
|
|
await page.pressKey('Enter');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Verbose log helper — prints when OPENCLI_VERBOSE or DEBUG=opencli is set.
|
|
*/
|
|
export function verbose(msg: string): void {
|
|
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
|
|
console.error(`[opencli:boss] ${msg}`);
|
|
}
|
|
}
|