@jackwener/opencli 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build-extension.yml +21 -3
- package/.github/workflows/docs.yml +52 -0
- package/README.md +28 -28
- package/README.zh-CN.md +28 -28
- package/dist/browser/cdp.d.ts +16 -1
- package/dist/browser/cdp.js +124 -80
- package/dist/browser/daemon-client.d.ts +3 -1
- package/dist/browser/daemon-client.js +4 -0
- package/dist/browser/dom-helpers.d.ts +20 -0
- package/dist/browser/dom-helpers.js +109 -0
- package/dist/browser/mcp.d.ts +1 -0
- package/dist/browser/mcp.js +10 -5
- package/dist/browser/page.d.ts +7 -0
- package/dist/browser/page.js +37 -100
- package/dist/browser.test.js +7 -0
- package/dist/build-manifest.js +3 -1
- package/dist/build-manifest.test.js +34 -0
- package/dist/capabilityRouting.d.ts +2 -0
- package/dist/capabilityRouting.js +30 -0
- package/dist/capabilityRouting.test.d.ts +1 -0
- package/dist/capabilityRouting.test.js +42 -0
- package/dist/chaoxing.test.js +11 -4
- package/dist/cli-manifest.json +635 -1
- package/dist/cli.js +48 -8
- package/dist/clis/antigravity/serve.d.ts +14 -0
- package/dist/clis/antigravity/serve.js +263 -0
- package/dist/clis/bilibili/download.js +4 -14
- package/dist/clis/boss/resume.d.ts +1 -0
- package/dist/clis/boss/resume.js +249 -0
- package/dist/clis/hf/top.d.ts +1 -0
- package/dist/clis/hf/top.js +119 -0
- package/dist/clis/jike/comment.d.ts +1 -0
- package/dist/clis/jike/comment.js +107 -0
- package/dist/clis/jike/create.d.ts +1 -0
- package/dist/clis/jike/create.js +106 -0
- package/dist/clis/jike/feed.d.ts +1 -0
- package/dist/clis/jike/feed.js +67 -0
- package/dist/clis/jike/like.d.ts +1 -0
- package/dist/clis/jike/like.js +61 -0
- package/dist/clis/jike/notifications.d.ts +1 -0
- package/dist/clis/jike/notifications.js +169 -0
- package/dist/clis/jike/post.yaml +58 -0
- package/dist/clis/jike/repost.d.ts +1 -0
- package/dist/clis/jike/repost.js +103 -0
- package/dist/clis/jike/search.d.ts +1 -0
- package/dist/clis/jike/search.js +67 -0
- package/dist/clis/jike/shared.d.ts +19 -0
- package/dist/clis/jike/shared.js +25 -0
- package/dist/clis/jike/topic.yaml +52 -0
- package/dist/clis/jike/user.yaml +51 -0
- package/dist/clis/smzdm/search.js +28 -39
- package/dist/clis/stackoverflow/bounties.yaml +29 -0
- package/dist/clis/stackoverflow/hot.yaml +28 -0
- package/dist/clis/stackoverflow/search.yaml +32 -0
- package/dist/clis/stackoverflow/unanswered.yaml +28 -0
- package/dist/clis/twitter/download.js +6 -16
- package/dist/clis/xiaohongshu/download.js +3 -3
- package/dist/clis/zhihu/download.js +3 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +16 -0
- package/dist/download/index.d.ts +12 -8
- package/dist/download/index.js +11 -3
- package/dist/download/index.test.d.ts +1 -0
- package/dist/download/index.test.js +14 -0
- package/dist/engine.js +5 -5
- package/dist/explore.d.ts +1 -0
- package/dist/explore.js +3 -3
- package/dist/generate.js +1 -0
- package/dist/interceptor.js +3 -2
- package/dist/output.d.ts +1 -0
- package/dist/output.js +3 -1
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.js +14 -18
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +5 -2
- package/dist/runtime.d.ts +4 -1
- package/dist/runtime.js +2 -2
- package/dist/types.d.ts +12 -0
- package/dist/verify.d.ts +6 -1
- package/dist/verify.js +54 -2
- package/docs/.vitepress/config.mts +193 -0
- package/docs/adapters/browser/apple-podcasts.md +28 -0
- package/docs/adapters/browser/bbc.md +26 -0
- package/docs/adapters/browser/bilibili.md +38 -0
- package/docs/adapters/browser/boss.md +28 -0
- package/docs/adapters/browser/coupang.md +28 -0
- package/docs/adapters/browser/ctrip.md +27 -0
- package/docs/adapters/browser/github.md +26 -0
- package/docs/adapters/browser/hackernews.md +26 -0
- package/docs/adapters/browser/linkedin.md +27 -0
- package/docs/adapters/browser/reddit.md +41 -0
- package/docs/adapters/browser/reuters.md +27 -0
- package/docs/adapters/browser/smzdm.md +27 -0
- package/docs/adapters/browser/twitter.md +47 -0
- package/docs/adapters/browser/v2ex.md +32 -0
- package/docs/adapters/browser/weibo.md +27 -0
- package/docs/adapters/browser/xiaohongshu.md +32 -0
- package/docs/adapters/browser/xiaoyuzhou.md +28 -0
- package/docs/adapters/browser/xueqiu.md +32 -0
- package/docs/adapters/browser/yahoo-finance.md +26 -0
- package/docs/adapters/browser/youtube.md +29 -0
- package/docs/adapters/browser/zhihu.md +30 -0
- package/docs/adapters/desktop/antigravity.md +46 -0
- package/docs/adapters/desktop/chatgpt.md +43 -0
- package/docs/adapters/desktop/chatwise.md +38 -0
- package/docs/adapters/desktop/codex.md +32 -0
- package/docs/adapters/desktop/cursor.md +33 -0
- package/docs/adapters/desktop/discord.md +28 -0
- package/docs/adapters/desktop/feishu.md +20 -0
- package/docs/adapters/desktop/neteasemusic.md +31 -0
- package/docs/adapters/desktop/notion.md +29 -0
- package/docs/adapters/desktop/wechat.md +28 -0
- package/docs/adapters/index.md +49 -0
- package/docs/advanced/cdp.md +103 -0
- package/docs/advanced/download.md +63 -0
- package/docs/advanced/electron.md +125 -0
- package/docs/advanced/remote-chrome.md +72 -0
- package/docs/developer/ai-workflow.md +66 -0
- package/docs/developer/architecture.md +90 -0
- package/docs/developer/contributing.md +136 -0
- package/docs/developer/testing.md +237 -0
- package/docs/developer/ts-adapter.md +87 -0
- package/docs/developer/yaml-adapter.md +108 -0
- package/docs/guide/browser-bridge.md +38 -0
- package/docs/guide/getting-started.md +56 -0
- package/docs/guide/installation.md +37 -0
- package/docs/guide/troubleshooting.md +56 -0
- package/docs/index.md +35 -0
- package/docs/zh/adapters/index.md +5 -0
- package/docs/zh/advanced/cdp.md +3 -0
- package/docs/zh/developer/contributing.md +24 -0
- package/docs/zh/guide/browser-bridge.md +25 -0
- package/docs/zh/guide/getting-started.md +40 -0
- package/docs/zh/guide/installation.md +37 -0
- package/docs/zh/index.md +29 -0
- package/extension/dist/background.js +92 -52
- package/extension/package-lock.json +1156 -0
- package/extension/src/background.test.ts +151 -0
- package/extension/src/background.ts +122 -51
- package/extension/src/protocol.ts +3 -1
- package/package.json +7 -3
- package/src/browser/cdp.ts +154 -82
- package/src/browser/daemon-client.ts +7 -1
- package/src/browser/dom-helpers.ts +116 -0
- package/src/browser/mcp.ts +14 -6
- package/src/browser/page.ts +45 -100
- package/src/browser.test.ts +10 -0
- package/src/build-manifest.test.ts +36 -0
- package/src/build-manifest.ts +2 -1
- package/src/capabilityRouting.test.ts +47 -0
- package/src/capabilityRouting.ts +28 -0
- package/src/chaoxing.test.ts +12 -4
- package/src/cli.ts +30 -8
- package/src/clis/antigravity/serve.ts +329 -0
- package/src/clis/bilibili/download.ts +4 -15
- package/src/clis/boss/resume.ts +262 -0
- package/src/clis/hf/top.ts +141 -0
- package/src/clis/jike/comment.ts +113 -0
- package/src/clis/jike/create.ts +113 -0
- package/src/clis/jike/feed.ts +74 -0
- package/src/clis/jike/like.ts +65 -0
- package/src/clis/jike/notifications.ts +185 -0
- package/src/clis/jike/post.yaml +58 -0
- package/src/clis/jike/repost.ts +114 -0
- package/src/clis/jike/search.ts +74 -0
- package/src/clis/jike/shared.ts +36 -0
- package/src/clis/jike/topic.yaml +52 -0
- package/src/clis/jike/user.yaml +51 -0
- package/src/clis/smzdm/search.ts +30 -39
- package/src/clis/stackoverflow/bounties.yaml +29 -0
- package/src/clis/stackoverflow/hot.yaml +28 -0
- package/src/clis/stackoverflow/search.yaml +32 -0
- package/src/clis/stackoverflow/unanswered.yaml +28 -0
- package/src/clis/twitter/download.ts +6 -17
- package/src/clis/xiaohongshu/download.ts +3 -3
- package/src/clis/zhihu/download.ts +3 -3
- package/src/doctor.ts +18 -2
- package/src/download/index.test.ts +16 -0
- package/src/download/index.ts +22 -4
- package/src/engine.ts +4 -4
- package/src/explore.ts +4 -4
- package/src/generate.ts +1 -0
- package/src/interceptor.ts +3 -2
- package/src/output.ts +3 -1
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.ts +14 -17
- package/src/registry.ts +6 -2
- package/src/runtime.ts +3 -2
- package/src/types.ts +9 -0
- package/src/verify.ts +64 -3
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* antigravity serve — Anthropic-compatible `/v1/messages` proxy server.
|
|
3
|
+
*
|
|
4
|
+
* Starts an HTTP server that accepts Anthropic Messages API requests,
|
|
5
|
+
* forwards them to a running Antigravity app via CDP, polls for the response,
|
|
6
|
+
* and returns it in Anthropic format.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve --port 8082
|
|
10
|
+
* ANTHROPIC_BASE_URL=http://localhost:8082 claude
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
14
|
+
import { CDPBridge } from '../../browser/cdp.js';
|
|
15
|
+
import type { IPage } from '../../types.js';
|
|
16
|
+
|
|
17
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface AnthropicRequest {
|
|
20
|
+
model?: string;
|
|
21
|
+
max_tokens?: number;
|
|
22
|
+
system?: string | Array<{ type: string; text: string }>;
|
|
23
|
+
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string }> }>;
|
|
24
|
+
stream?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AnthropicResponse {
|
|
28
|
+
id: string;
|
|
29
|
+
type: 'message';
|
|
30
|
+
role: 'assistant';
|
|
31
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
32
|
+
model: string;
|
|
33
|
+
stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence';
|
|
34
|
+
stop_sequence: null;
|
|
35
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function generateMsgId(): string {
|
|
41
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
42
|
+
let id = 'msg_';
|
|
43
|
+
for (let i = 0; i < 24; i++) id += chars[Math.floor(Math.random() * chars.length)];
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function estimateTokens(text: string): number {
|
|
48
|
+
// Rough approximation: ~4 chars per token for English, ~2 for CJK
|
|
49
|
+
return Math.max(1, Math.ceil(text.length / 3));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
|
|
53
|
+
if (typeof content === 'string') return content;
|
|
54
|
+
return content
|
|
55
|
+
.filter(b => b.type === 'text' && b.text)
|
|
56
|
+
.map(b => b.text!)
|
|
57
|
+
.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const chunks: Buffer[] = [];
|
|
63
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
64
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
65
|
+
req.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
|
|
70
|
+
const body = JSON.stringify(data);
|
|
71
|
+
res.writeHead(status, {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'Access-Control-Allow-Origin': '*',
|
|
74
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
75
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization',
|
|
76
|
+
});
|
|
77
|
+
res.end(body);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sleep(ms: number): Promise<void> {
|
|
81
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Antigravity CDP Operations ──────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
async function getConversationText(page: IPage): Promise<string> {
|
|
87
|
+
const text = await page.evaluate(`
|
|
88
|
+
(() => {
|
|
89
|
+
const container = document.getElementById('conversation');
|
|
90
|
+
return container ? container.innerText : '';
|
|
91
|
+
})()
|
|
92
|
+
`);
|
|
93
|
+
return String(text ?? '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function sendMessage(page: IPage, message: string): Promise<void> {
|
|
97
|
+
await page.evaluate(`
|
|
98
|
+
(async () => {
|
|
99
|
+
const container = document.getElementById('antigravity.agentSidePanelInputBox');
|
|
100
|
+
if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
|
|
101
|
+
const editor = container.querySelector('[data-lexical-editor="true"]');
|
|
102
|
+
if (!editor) throw new Error('Could not find Antigravity input box');
|
|
103
|
+
|
|
104
|
+
editor.focus();
|
|
105
|
+
document.execCommand('insertText', false, ${JSON.stringify(message)});
|
|
106
|
+
})()
|
|
107
|
+
`);
|
|
108
|
+
await sleep(500);
|
|
109
|
+
await page.pressKey('Enter');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function waitForReply(
|
|
113
|
+
page: IPage,
|
|
114
|
+
beforeText: string,
|
|
115
|
+
opts: { timeout?: number; pollInterval?: number; stableThreshold?: number } = {},
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
const timeout = opts.timeout ?? 120_000; // 2 minutes max
|
|
118
|
+
const pollInterval = opts.pollInterval ?? 500; // 500ms polling
|
|
119
|
+
const stableThreshold = opts.stableThreshold ?? 6; // 6 × 500ms = 3s stable
|
|
120
|
+
|
|
121
|
+
const deadline = Date.now() + timeout;
|
|
122
|
+
let lastText = beforeText;
|
|
123
|
+
let stableCount = 0;
|
|
124
|
+
|
|
125
|
+
// Wait a bit for the model to start generating
|
|
126
|
+
await sleep(1000);
|
|
127
|
+
|
|
128
|
+
while (Date.now() < deadline) {
|
|
129
|
+
const current = await getConversationText(page);
|
|
130
|
+
|
|
131
|
+
if (current.length > beforeText.length) {
|
|
132
|
+
// New content appeared
|
|
133
|
+
if (current === lastText) {
|
|
134
|
+
stableCount++;
|
|
135
|
+
if (stableCount >= stableThreshold) {
|
|
136
|
+
// Text has been stable — reply is complete
|
|
137
|
+
return current.slice(beforeText.length).trim();
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
// Still generating
|
|
141
|
+
stableCount = 0;
|
|
142
|
+
lastText = current;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await sleep(pollInterval);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Timeout — return whatever we have
|
|
150
|
+
const finalText = await getConversationText(page);
|
|
151
|
+
if (finalText.length > beforeText.length) {
|
|
152
|
+
return finalText.slice(beforeText.length).trim();
|
|
153
|
+
}
|
|
154
|
+
throw new Error('Timeout waiting for Antigravity reply');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Request Handlers ────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async function handleMessages(
|
|
160
|
+
body: AnthropicRequest,
|
|
161
|
+
page: IPage,
|
|
162
|
+
): Promise<AnthropicResponse> {
|
|
163
|
+
// Extract the last user message
|
|
164
|
+
const userMessages = body.messages.filter(m => m.role === 'user');
|
|
165
|
+
if (userMessages.length === 0) {
|
|
166
|
+
throw new Error('No user message found in request');
|
|
167
|
+
}
|
|
168
|
+
const lastUserMsg = userMessages[userMessages.length - 1];
|
|
169
|
+
const userText = extractTextContent(lastUserMsg.content);
|
|
170
|
+
|
|
171
|
+
if (!userText.trim()) {
|
|
172
|
+
throw new Error('Empty user message');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Get conversation state before sending
|
|
176
|
+
const beforeText = await getConversationText(page);
|
|
177
|
+
|
|
178
|
+
// Send the message
|
|
179
|
+
console.error(`[serve] Sending: "${userText.slice(0, 80)}${userText.length > 80 ? '...' : ''}"`);
|
|
180
|
+
await sendMessage(page, userText);
|
|
181
|
+
|
|
182
|
+
// Poll for reply
|
|
183
|
+
console.error('[serve] Waiting for reply...');
|
|
184
|
+
const replyText = await waitForReply(page, beforeText);
|
|
185
|
+
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
id: generateMsgId(),
|
|
189
|
+
type: 'message',
|
|
190
|
+
role: 'assistant',
|
|
191
|
+
content: [{ type: 'text', text: replyText }],
|
|
192
|
+
model: body.model ?? 'antigravity',
|
|
193
|
+
stop_reason: 'end_turn',
|
|
194
|
+
stop_sequence: null,
|
|
195
|
+
usage: {
|
|
196
|
+
input_tokens: estimateTokens(userText),
|
|
197
|
+
output_tokens: estimateTokens(replyText),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Server ──────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export async function startServe(opts: { port?: number } = {}): Promise<void> {
|
|
205
|
+
const port = opts.port ?? 8082;
|
|
206
|
+
|
|
207
|
+
// Establish persistent CDP connection
|
|
208
|
+
console.error('[serve] Connecting to Antigravity via CDP...');
|
|
209
|
+
const cdp = new CDPBridge();
|
|
210
|
+
const page = await cdp.connect({ timeout: 15_000 });
|
|
211
|
+
console.error('[serve] CDP connected successfully.');
|
|
212
|
+
|
|
213
|
+
// Verify we can read conversation
|
|
214
|
+
const testText = await getConversationText(page);
|
|
215
|
+
console.error(`[serve] Conversation element found (${testText.length} chars).`);
|
|
216
|
+
|
|
217
|
+
let requestInFlight = false;
|
|
218
|
+
|
|
219
|
+
const server = createServer(async (req, res) => {
|
|
220
|
+
// CORS preflight
|
|
221
|
+
if (req.method === 'OPTIONS') {
|
|
222
|
+
res.writeHead(204, {
|
|
223
|
+
'Access-Control-Allow-Origin': '*',
|
|
224
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
225
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization',
|
|
226
|
+
});
|
|
227
|
+
res.end();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const url = req.url ?? '/';
|
|
232
|
+
const pathname = url.split('?')[0];
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// GET /v1/models — return available models
|
|
236
|
+
if (req.method === 'GET' && pathname === '/v1/models') {
|
|
237
|
+
jsonResponse(res, 200, {
|
|
238
|
+
data: [
|
|
239
|
+
{
|
|
240
|
+
id: 'antigravity',
|
|
241
|
+
object: 'model',
|
|
242
|
+
created: Math.floor(Date.now() / 1000),
|
|
243
|
+
owned_by: 'antigravity',
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// POST /v1/messages — main endpoint
|
|
251
|
+
if (req.method === 'POST' && pathname === '/v1/messages') {
|
|
252
|
+
if (requestInFlight) {
|
|
253
|
+
jsonResponse(res, 429, {
|
|
254
|
+
type: 'error',
|
|
255
|
+
error: {
|
|
256
|
+
type: 'rate_limit_error',
|
|
257
|
+
message: 'Another request is currently being processed. Antigravity can only handle one request at a time.',
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
requestInFlight = true;
|
|
264
|
+
try {
|
|
265
|
+
const rawBody = await readBody(req);
|
|
266
|
+
const body = JSON.parse(rawBody) as AnthropicRequest;
|
|
267
|
+
|
|
268
|
+
if (body.stream) {
|
|
269
|
+
// We don't support streaming — return error
|
|
270
|
+
jsonResponse(res, 400, {
|
|
271
|
+
type: 'error',
|
|
272
|
+
error: {
|
|
273
|
+
type: 'invalid_request_error',
|
|
274
|
+
message: 'Streaming is not supported. Set "stream": false.',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const response = await handleMessages(body, page);
|
|
281
|
+
jsonResponse(res, 200, response);
|
|
282
|
+
} finally {
|
|
283
|
+
requestInFlight = false;
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Health check
|
|
289
|
+
if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) {
|
|
290
|
+
jsonResponse(res, 200, { ok: true, status: 'connected' });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
jsonResponse(res, 404, {
|
|
295
|
+
type: 'error',
|
|
296
|
+
error: { type: 'not_found_error', message: `Not found: ${pathname}` },
|
|
297
|
+
});
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error('[serve] Error:', err);
|
|
300
|
+
jsonResponse(res, 500, {
|
|
301
|
+
type: 'error',
|
|
302
|
+
error: {
|
|
303
|
+
type: 'api_error',
|
|
304
|
+
message: err instanceof Error ? err.message : 'Internal server error',
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
server.listen(port, '127.0.0.1', () => {
|
|
311
|
+
console.error(`\n[serve] ✅ Antigravity API proxy running at http://127.0.0.1:${port}`);
|
|
312
|
+
console.error(`[serve] Compatible with Anthropic /v1/messages API`);
|
|
313
|
+
console.error(`\n[serve] Usage with Claude Code:`);
|
|
314
|
+
console.error(` ANTHROPIC_BASE_URL=http://localhost:${port} claude\n`);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Graceful shutdown
|
|
318
|
+
const shutdown = () => {
|
|
319
|
+
console.error('\n[serve] Shutting down...');
|
|
320
|
+
cdp.close().catch(() => {});
|
|
321
|
+
server.close();
|
|
322
|
+
process.exit(0);
|
|
323
|
+
};
|
|
324
|
+
process.on('SIGTERM', shutdown);
|
|
325
|
+
process.on('SIGINT', shutdown);
|
|
326
|
+
|
|
327
|
+
// Keep alive
|
|
328
|
+
await new Promise(() => {});
|
|
329
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
sanitizeFilename,
|
|
18
18
|
getTempDir,
|
|
19
19
|
exportCookiesToNetscape,
|
|
20
|
+
formatCookieHeader,
|
|
20
21
|
} from '../../download/index.js';
|
|
21
22
|
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
|
|
22
23
|
|
|
@@ -63,30 +64,18 @@ cli({
|
|
|
63
64
|
const title = sanitizeFilename(data?.title || 'video');
|
|
64
65
|
|
|
65
66
|
// Extract cookies for authenticated downloads
|
|
66
|
-
const
|
|
67
|
+
const cookies = await page.getCookies({ domain: 'bilibili.com' });
|
|
68
|
+
const cookieString = formatCookieHeader(cookies);
|
|
67
69
|
|
|
68
70
|
// Create output directory
|
|
69
71
|
fs.mkdirSync(output, { recursive: true });
|
|
70
72
|
|
|
71
73
|
// Export cookies to Netscape format for yt-dlp
|
|
72
74
|
let cookiesFile: string | undefined;
|
|
73
|
-
if (
|
|
75
|
+
if (cookies.length > 0) {
|
|
74
76
|
const tempDir = getTempDir();
|
|
75
77
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
76
78
|
cookiesFile = path.join(tempDir, `bilibili_cookies_${Date.now()}.txt`);
|
|
77
|
-
|
|
78
|
-
const cookies = cookieString.split(';').map((c) => {
|
|
79
|
-
const [name, ...rest] = c.trim().split('=');
|
|
80
|
-
return {
|
|
81
|
-
name: name || '',
|
|
82
|
-
value: rest.join('=') || '',
|
|
83
|
-
domain: '.bilibili.com',
|
|
84
|
-
path: '/',
|
|
85
|
-
secure: true,
|
|
86
|
-
httpOnly: false,
|
|
87
|
-
};
|
|
88
|
-
}).filter((c) => c.name);
|
|
89
|
-
|
|
90
79
|
exportCookiesToNetscape(cookies, cookiesFile);
|
|
91
80
|
}
|
|
92
81
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOSS直聘 resume — view candidate resume/profile via chat page UI scraping (boss side).
|
|
3
|
+
*
|
|
4
|
+
* Flow: navigate to chat page → click on candidate → scrape the right panel info.
|
|
5
|
+
* The chat page loads candidate basic info, work experience, and education
|
|
6
|
+
* in the right panel when a candidate is selected.
|
|
7
|
+
*
|
|
8
|
+
* HTML structure (right panel):
|
|
9
|
+
* .base-info-single-detial → name, gender, age, experience, degree
|
|
10
|
+
* .experience-content.time-list → time ranges (icon-base-info-work / icon-base-info-edu)
|
|
11
|
+
* .experience-content.detail-list → details (company·position / school·major·degree)
|
|
12
|
+
* .position-content → job being discussed + expectation
|
|
13
|
+
*/
|
|
14
|
+
import { cli, Strategy } from '../../registry.js';
|
|
15
|
+
import type { IPage } from '../../types.js';
|
|
16
|
+
|
|
17
|
+
cli({
|
|
18
|
+
site: 'boss',
|
|
19
|
+
name: 'resume',
|
|
20
|
+
description: 'BOSS直聘查看候选人简历(招聘端)',
|
|
21
|
+
domain: 'www.zhipin.com',
|
|
22
|
+
strategy: Strategy.COOKIE,
|
|
23
|
+
|
|
24
|
+
browser: true,
|
|
25
|
+
args: [
|
|
26
|
+
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate (from chatlist)' },
|
|
27
|
+
],
|
|
28
|
+
columns: [
|
|
29
|
+
'name', 'gender', 'age', 'experience', 'degree', 'active_time',
|
|
30
|
+
'work_history', 'education',
|
|
31
|
+
'job_chatting', 'expect',
|
|
32
|
+
],
|
|
33
|
+
func: async (page: IPage | null, kwargs) => {
|
|
34
|
+
if (!page) throw new Error('Browser page required');
|
|
35
|
+
|
|
36
|
+
const uid = kwargs.uid;
|
|
37
|
+
|
|
38
|
+
// Step 1: Navigate to chat page
|
|
39
|
+
await page.goto('https://www.zhipin.com/web/chat/index');
|
|
40
|
+
await page.wait({ time: 3 });
|
|
41
|
+
|
|
42
|
+
// Step 2: Get friend list to find candidate's numeric uid
|
|
43
|
+
const friendData: any = await page.evaluate(`
|
|
44
|
+
async () => {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const xhr = new XMLHttpRequest();
|
|
47
|
+
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
|
|
48
|
+
xhr.withCredentials = true;
|
|
49
|
+
xhr.timeout = 15000;
|
|
50
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
51
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
|
|
52
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
53
|
+
xhr.send();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
if (friendData.code !== 0) {
|
|
59
|
+
if (friendData.code === 7 || friendData.code === 37) {
|
|
60
|
+
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
|
|
61
|
+
}
|
|
62
|
+
throw new Error('获取好友列表失败: ' + (friendData.message || friendData.code));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let friend: any = null;
|
|
66
|
+
const allFriends = friendData.zpData?.friendList || [];
|
|
67
|
+
friend = allFriends.find((f: any) => f.encryptUid === uid);
|
|
68
|
+
|
|
69
|
+
if (!friend) {
|
|
70
|
+
for (let p = 2; p <= 5; p++) {
|
|
71
|
+
const moreUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${p}&status=0&jobId=0`;
|
|
72
|
+
const moreData: any = await page.evaluate(`
|
|
73
|
+
async () => {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const xhr = new XMLHttpRequest();
|
|
76
|
+
xhr.open('GET', '${moreUrl}', true);
|
|
77
|
+
xhr.withCredentials = true;
|
|
78
|
+
xhr.timeout = 15000;
|
|
79
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
80
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
|
|
81
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
82
|
+
xhr.send();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
`);
|
|
86
|
+
if (moreData.code === 0) {
|
|
87
|
+
const list = moreData.zpData?.friendList || [];
|
|
88
|
+
friend = list.find((f: any) => f.encryptUid === uid);
|
|
89
|
+
if (friend) break;
|
|
90
|
+
if (list.length === 0) break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
|
|
96
|
+
|
|
97
|
+
const numericUid = friend.uid;
|
|
98
|
+
|
|
99
|
+
// Step 3: Click on candidate in chat list
|
|
100
|
+
const clicked: any = await page.evaluate(`
|
|
101
|
+
async () => {
|
|
102
|
+
const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
|
|
103
|
+
if (item) {
|
|
104
|
+
item.click();
|
|
105
|
+
return { clicked: true };
|
|
106
|
+
}
|
|
107
|
+
const items = document.querySelectorAll('.geek-item');
|
|
108
|
+
for (const el of items) {
|
|
109
|
+
if (el.id && el.id.startsWith('_${numericUid}')) {
|
|
110
|
+
el.click();
|
|
111
|
+
return { clicked: true };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { clicked: false };
|
|
115
|
+
}
|
|
116
|
+
`);
|
|
117
|
+
|
|
118
|
+
if (!clicked.clicked) {
|
|
119
|
+
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Step 4: Wait for right panel to load
|
|
123
|
+
await page.wait({ time: 2 });
|
|
124
|
+
|
|
125
|
+
// Step 5: Scrape the right panel
|
|
126
|
+
const resumeInfo: any = await page.evaluate(`
|
|
127
|
+
(() => {
|
|
128
|
+
const container = document.querySelector('.base-info-single-container') || document.querySelector('.base-info-content');
|
|
129
|
+
if (!container) return { error: 'no container found' };
|
|
130
|
+
|
|
131
|
+
// === Basic Info ===
|
|
132
|
+
const nameEl = container.querySelector('.base-name');
|
|
133
|
+
const name = nameEl ? nameEl.textContent.trim() : '';
|
|
134
|
+
|
|
135
|
+
// Gender
|
|
136
|
+
let gender = '';
|
|
137
|
+
const detailDiv = container.querySelector('.base-info-single-detial');
|
|
138
|
+
if (detailDiv) {
|
|
139
|
+
const uses = detailDiv.querySelectorAll('use');
|
|
140
|
+
for (const u of uses) {
|
|
141
|
+
const href = u.getAttribute('xlink:href') || u.getAttribute('href') || '';
|
|
142
|
+
if (href.includes('icon-men')) { gender = '男'; break; }
|
|
143
|
+
if (href.includes('icon-women')) { gender = '女'; break; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Active time
|
|
148
|
+
const activeEl = container.querySelector('.active-time');
|
|
149
|
+
const activeTime = activeEl ? activeEl.textContent.trim() : '';
|
|
150
|
+
|
|
151
|
+
// Age, experience, degree — direct child divs of .base-info-single-detial
|
|
152
|
+
let age = '', experience = '', degree = '';
|
|
153
|
+
if (detailDiv) {
|
|
154
|
+
for (const el of detailDiv.children) {
|
|
155
|
+
if (el.classList.contains('name-contet') || el.classList.contains('high-light-orange') ||
|
|
156
|
+
el.classList.contains('resume-btn-content') || el.classList.contains('label-remark-content') ||
|
|
157
|
+
el.classList.contains('base-info-item')) continue;
|
|
158
|
+
const text = el.textContent.trim();
|
|
159
|
+
if (!text) continue;
|
|
160
|
+
if (text.match(/\\d+岁/)) age = text;
|
|
161
|
+
else if (text.match(/年|经验|应届/)) experience = text;
|
|
162
|
+
else if (['博士', '硕士', '本科', '大专', '高中', '中专', '中技', '初中'].some(d => text.includes(d))) degree = text;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// === Work & Education ===
|
|
167
|
+
// Structure: two .experience-content divs
|
|
168
|
+
// 1. .time-list → <li> items with icon (work/edu) and time span
|
|
169
|
+
// 2. .detail-list → <li> items with icon (work/edu) and detail text
|
|
170
|
+
// Each <li> has a <use> with xlink:href "#icon-base-info-work" or "#icon-base-info-edu"
|
|
171
|
+
|
|
172
|
+
const workTimes = [];
|
|
173
|
+
const eduTimes = [];
|
|
174
|
+
const workDetails = [];
|
|
175
|
+
const eduDetails = [];
|
|
176
|
+
|
|
177
|
+
const timeList = container.querySelector('.experience-content.time-list');
|
|
178
|
+
if (timeList) {
|
|
179
|
+
const lis = timeList.querySelectorAll('li');
|
|
180
|
+
for (const li of lis) {
|
|
181
|
+
const useEl = li.querySelector('use');
|
|
182
|
+
const href = useEl ? (useEl.getAttribute('xlink:href') || useEl.getAttribute('href') || '') : '';
|
|
183
|
+
const timeSpan = li.querySelector('.time');
|
|
184
|
+
const timeText = timeSpan ? timeSpan.textContent.trim() : li.textContent.trim();
|
|
185
|
+
if (href.includes('base-info-edu')) {
|
|
186
|
+
eduTimes.push(timeText);
|
|
187
|
+
} else {
|
|
188
|
+
workTimes.push(timeText);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const detailList = container.querySelector('.experience-content.detail-list');
|
|
194
|
+
if (detailList) {
|
|
195
|
+
const lis = detailList.querySelectorAll('li');
|
|
196
|
+
for (const li of lis) {
|
|
197
|
+
const useEl = li.querySelector('use');
|
|
198
|
+
const href = useEl ? (useEl.getAttribute('xlink:href') || useEl.getAttribute('href') || '') : '';
|
|
199
|
+
const valueSpan = li.querySelector('.value');
|
|
200
|
+
const valueText = valueSpan ? valueSpan.textContent.trim() : li.textContent.trim();
|
|
201
|
+
if (href.includes('base-info-edu')) {
|
|
202
|
+
eduDetails.push(valueText);
|
|
203
|
+
} else {
|
|
204
|
+
workDetails.push(valueText);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Combine times and details
|
|
210
|
+
const workHistory = [];
|
|
211
|
+
for (let i = 0; i < Math.max(workTimes.length, workDetails.length); i++) {
|
|
212
|
+
const parts = [];
|
|
213
|
+
if (workTimes[i]) parts.push(workTimes[i]);
|
|
214
|
+
if (workDetails[i]) parts.push(workDetails[i]);
|
|
215
|
+
if (parts.length) workHistory.push(parts.join(' '));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const education = [];
|
|
219
|
+
for (let i = 0; i < Math.max(eduTimes.length, eduDetails.length); i++) {
|
|
220
|
+
const parts = [];
|
|
221
|
+
if (eduTimes[i]) parts.push(eduTimes[i]);
|
|
222
|
+
if (eduDetails[i]) parts.push(eduDetails[i]);
|
|
223
|
+
if (parts.length) education.push(parts.join(' '));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// === Job Chatting & Expect ===
|
|
227
|
+
const positionContent = container.querySelector('.position-content');
|
|
228
|
+
let jobChatting = '', expect = '';
|
|
229
|
+
if (positionContent) {
|
|
230
|
+
const posNameEl = positionContent.querySelector('.position-name');
|
|
231
|
+
if (posNameEl) jobChatting = posNameEl.textContent.trim();
|
|
232
|
+
|
|
233
|
+
const expectEl = positionContent.querySelector('.position-item.expect .value');
|
|
234
|
+
if (expectEl) expect = expectEl.textContent.trim();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
name, gender, age, experience, degree, activeTime,
|
|
239
|
+
workHistory, education,
|
|
240
|
+
jobChatting, expect,
|
|
241
|
+
};
|
|
242
|
+
})()
|
|
243
|
+
`);
|
|
244
|
+
|
|
245
|
+
if (resumeInfo.error) {
|
|
246
|
+
throw new Error('无法获取简历面板: ' + resumeInfo.error);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return [{
|
|
250
|
+
name: resumeInfo.name || friend.name || '',
|
|
251
|
+
gender: resumeInfo.gender || '',
|
|
252
|
+
age: resumeInfo.age || '',
|
|
253
|
+
experience: resumeInfo.experience || '',
|
|
254
|
+
degree: resumeInfo.degree || '',
|
|
255
|
+
active_time: resumeInfo.activeTime || '',
|
|
256
|
+
work_history: (resumeInfo.workHistory || []).join('\\n') || '(未获取到)',
|
|
257
|
+
education: (resumeInfo.education || []).join('\\n') || '(未获取到)',
|
|
258
|
+
job_chatting: resumeInfo.jobChatting || '',
|
|
259
|
+
expect: resumeInfo.expect || '',
|
|
260
|
+
}];
|
|
261
|
+
},
|
|
262
|
+
});
|