@xbrowser/zhihu 2.1.0

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.
Files changed (2) hide show
  1. package/index.ts +912 -0
  2. package/package.json +29 -0
package/index.ts ADDED
@@ -0,0 +1,912 @@
1
+ import { z } from 'zod';
2
+ import type { XCLIAPI, CommandContext } from '@dyyz1993/xcli-core';
3
+ import { ok, fail } from '@dyyz1993/xcli-core';
4
+ import type { Page } from 'playwright';
5
+
6
+ const ZHIDA_URL = 'https://zhida.zhihu.com';
7
+
8
+ /** 思考模式映射 */
9
+ const THINKING_MODE_MAP: Record<string, string> = {
10
+ smart: '智能思考',
11
+ deep: '深度思考',
12
+ fast: '快速回答',
13
+ };
14
+
15
+ /** 知识来源映射 */
16
+ const SOURCE_MAP: Record<string, string> = {
17
+ all: '全网',
18
+ zhihu: '知乎',
19
+ academic: '学术',
20
+ my: '我的知识库',
21
+ };
22
+
23
+ function resolvePage(ctx: Record<string, unknown>): { page: Page; tips: string[] } {
24
+ const page = ctx.page as Page;
25
+ if (!page) throw new Error('需要浏览器页面');
26
+ const cdpEndpoint = ctx.cdpEndpoint as string | undefined;
27
+ const sessionId = ctx.sessionId as string | undefined;
28
+ const tips: string[] = [];
29
+ if (!cdpEndpoint) {
30
+ tips.push('建议使用 --cdp 9221 连接 Chrome 浏览器以获取登录态(知乎知答需要登录)');
31
+ }
32
+ tips.push(`Session: ${sessionId || 'default'}`);
33
+ return { page, tips };
34
+ }
35
+
36
+ /** 安全点击选择器(CDP 模式兼容) */
37
+ async function safeClick(page: Page, selector: string): Promise<boolean> {
38
+ try {
39
+ const handle = await page.evaluateHandle((sel: string) => {
40
+ const el = document.querySelector(sel);
41
+ return el;
42
+ }, selector);
43
+ const element = handle.asElement();
44
+ if (!element) return false;
45
+ const box = await element.boundingBox();
46
+ if (!box) return false;
47
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /** 确保在知乎知答页面且已登录 */
55
+ async function ensureZhidaPage(page: Page, ctx?: CommandContext): Promise<void> {
56
+ const currentUrl = page.url();
57
+
58
+ // 如果不在知乎知答页面,导航过去
59
+ if (!currentUrl.includes('zhida.zhihu.com')) {
60
+ console.log(` [nav] 导航到知乎知答: ${ZHIDA_URL}`);
61
+ await page.goto(ZHIDA_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
62
+
63
+ // 等待页面加载
64
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
65
+ await page.waitForTimeout(3000);
66
+
67
+ // 再次检查 URL
68
+ const finalUrl = page.url();
69
+ console.log(` [nav] 最终 URL: ${finalUrl}`);
70
+ }
71
+
72
+ // 检查是否跳转到登录页
73
+ const bodyText = await page.evaluate(() => document.body?.textContent?.trim().slice(0, 1000) || '');
74
+ const isLoginPage = bodyText.includes('登录') && bodyText.includes('注册');
75
+
76
+ // 如果有知乎相关的文字,说明不是纯登录页
77
+ const hasZhihuContent = bodyText.includes('知乎直答') || bodyText.includes('知答') || bodyText.includes('AI 搜索');
78
+
79
+ if (isLoginPage && !hasZhihuContent) {
80
+ const cdp = (ctx as unknown as Record<string, unknown>)?.cdpEndpoint;
81
+ throw new Error(
82
+ '知乎知答 (zhida) 未登录!\n' +
83
+ (cdp
84
+ ? ' 使用 --cdp 连接的浏览器未登录知乎,请先在浏览器中登录。\n 请手动打开 https://zhida.zhihu.com 登录后再试。'
85
+ : ' 请使用 --cdp 参数连接已登录的浏览器:\n xbrowser zhihu chat "你的问题" --cdp http://localhost:9221')
86
+ );
87
+ }
88
+
89
+ // 验证输入框是否存在
90
+ const editorExists = await page.evaluate(() => {
91
+ const editor = document.querySelector('.public-DraftEditor-content');
92
+ return !!editor;
93
+ });
94
+
95
+ if (!editorExists) {
96
+ console.log(' [nav] ⚠ 输入框未找到,页面可能仍在加载...');
97
+ }
98
+ }
99
+
100
+ /** 选择思考模式下拉菜单 */
101
+ async function selectThinkingMode(page: Page, mode: string): Promise<void> {
102
+ const targetLabel = THINKING_MODE_MAP[mode];
103
+ if (!targetLabel) throw new Error(`无效的思考模式: ${mode},可选值: ${Object.keys(THINKING_MODE_MAP).join(', ')}`);
104
+
105
+ // 检查当前是否已选中目标模式
106
+ const currentMode = await page.evaluate(() => {
107
+ const el = Array.from(document.querySelectorAll('*')).find(e =>
108
+ e.textContent?.trim() === '智能思考' || e.textContent?.trim() === '深度思考' || e.textContent?.trim() === '快速回答'
109
+ );
110
+ return el?.textContent?.trim();
111
+ });
112
+
113
+ if (currentMode === targetLabel) {
114
+ console.log(` [mode] 已是目标模式: ${targetLabel}`);
115
+ return;
116
+ }
117
+
118
+ // 点击"智能思考"按钮打开下拉菜单
119
+ const clicked = await page.evaluate(() => {
120
+ const els = Array.from(document.querySelectorAll('*'));
121
+ const target = els.find(el =>
122
+ ['智能思考', '深度思考', '快速回答'].includes(el.textContent?.trim() || '') &&
123
+ el.children.length <= 2
124
+ );
125
+ if (target) {
126
+ (target as HTMLElement).click();
127
+ return true;
128
+ }
129
+ return false;
130
+ });
131
+
132
+ if (!clicked) throw new Error('无法找到思考模式选择器');
133
+ await page.waitForTimeout(800);
134
+
135
+ // 点击目标选项
136
+ const selected = await page.evaluate((label: string) => {
137
+ const els = Array.from(document.querySelectorAll('*'));
138
+ const target = els.find(el => el.textContent?.trim() === label && el.children.length <= 1);
139
+ if (target && target.offsetParent !== null) {
140
+ (target as HTMLElement).click();
141
+ return true;
142
+ }
143
+ return false;
144
+ }, targetLabel);
145
+
146
+ if (!selected) {
147
+ // 尝试关闭下拉菜单并继续
148
+ await page.keyboard.press('Escape');
149
+ console.log(` [mode] ⚠ 无法选择 "${targetLabel}",使用默认模式`);
150
+ } else {
151
+ console.log(` [mode] ✓ 已选择: ${targetLabel}`);
152
+ await page.waitForTimeout(500);
153
+ }
154
+ }
155
+
156
+ /** 选择知识来源下拉菜单 */
157
+ async function selectKnowledgeSource(page: Page, source: string): Promise<void> {
158
+ const targetLabel = SOURCE_MAP[source];
159
+ if (!targetLabel) throw new Error(`无效的知识来源: ${source},可选值: ${Object.keys(SOURCE_MAP).join(', ')}`);
160
+
161
+ // 知识来源选择器在思考模式的右边,点击它打开下拉菜单
162
+ // 基于UI布局: 智能思考 ▼ | 🌐知🎓📚 ▼ | @ | 📎 | ↑
163
+ const clicked = await page.evaluate(() => {
164
+ // 找到思考模式元素,然后点击它右边的兄弟元素(知识来源选择器)
165
+ const thinkEl = Array.from(document.querySelectorAll('*')).find(el =>
166
+ ['智能思考', '深度思考', '快速回答'].includes(el.textContent?.trim() || '') &&
167
+ el.children.length <= 2
168
+ );
169
+ if (!thinkEl) return false;
170
+
171
+ // 找到父容器中在思考模式右边的可点击元素
172
+ const parent = thinkEl.parentElement;
173
+ if (parent) {
174
+ const children = Array.from(parent.children);
175
+ const thinkIdx = children.indexOf(thinkEl);
176
+ // 思考模式后面的 1-2 个元素可能是知识来源选择器
177
+ for (let i = thinkIdx + 1; i < Math.min(thinkIdx + 3, children.length); i++) {
178
+ const sibling = children[i] as HTMLElement;
179
+ if (sibling.offsetWidth > 0 && sibling.offsetWidth < 200) {
180
+ sibling.click();
181
+ return true;
182
+ }
183
+ }
184
+ }
185
+
186
+ // 备选:找下一个兄弟元素
187
+ let next = thinkEl.nextElementSibling;
188
+ while (next) {
189
+ const r = next.getBoundingClientRect();
190
+ if (r.width > 0 && r.width < 200) {
191
+ (next as HTMLElement).click();
192
+ return true;
193
+ }
194
+ next = next.nextElementSibling;
195
+ }
196
+ return false;
197
+ });
198
+
199
+ if (!clicked) {
200
+ console.log(` [source] ⚠ 无法找到知识来源选择器,使用默认`);
201
+ return;
202
+ }
203
+
204
+ await page.waitForTimeout(800);
205
+
206
+ // 点击目标选项
207
+ const selected = await page.evaluate((label: string) => {
208
+ const els = Array.from(document.querySelectorAll('*'));
209
+ const targets = els.filter(el => el.textContent?.trim() === label && el.children.length <= 1 && el.offsetParent !== null);
210
+ if (targets.length > 0) {
211
+ (targets[0] as HTMLElement).click();
212
+ return true;
213
+ }
214
+ return false;
215
+ }, targetLabel);
216
+
217
+ if (!selected) {
218
+ await page.keyboard.press('Escape');
219
+ console.log(` [source] ⚠ 无法选择 "${targetLabel}",使用默认来源`);
220
+ } else {
221
+ console.log(` [source] ✓ 已选择: ${targetLabel}`);
222
+ await page.waitForTimeout(500);
223
+ }
224
+ }
225
+
226
+ /** 在 DraftEditor 中输入文本 */
227
+ async function typeInDraftEditor(page: Page, text: string): Promise<void> {
228
+ // 点击输入框区域
229
+ const editorClicked = await safeClick(page, '.public-DraftEditor-content');
230
+ if (!editorClicked) {
231
+ // 备选:用 evaluate 点击
232
+ await page.evaluate(() => {
233
+ const editor = document.querySelector('.public-DraftEditor-content');
234
+ if (editor) (editor as HTMLElement).click();
235
+ });
236
+ }
237
+ await page.waitForTimeout(500);
238
+
239
+ // 清空输入框(先全选再删除)
240
+ await page.keyboard.down('Control');
241
+ await page.keyboard.press('a');
242
+ await page.keyboard.up('Control');
243
+ await page.waitForTimeout(100);
244
+ await page.keyboard.press('Backspace');
245
+ await page.waitForTimeout(200);
246
+
247
+ // 使用 keyboard 输入(DraftJS 兼容)
248
+ await page.keyboard.type(text, { delay: 10 });
249
+ await page.waitForTimeout(300);
250
+ }
251
+
252
+ /** 点击发送按钮 */
253
+ async function clickSendButton(page: Page): Promise<boolean> {
254
+ // 发送按钮通常是输入框右侧的向上箭头图标 (SVG)
255
+ const sent = await page.evaluate(() => {
256
+ // 获取输入框位置
257
+ const editor = document.querySelector('.public-DraftEditor-content');
258
+ if (!editor) return 'editor_not_found';
259
+
260
+ const editorRect = editor.getBoundingClientRect();
261
+
262
+ // 查找附近的 SVG,优先找较大的那个(发送按钮通常比选项图标大)
263
+ const svgs = document.querySelectorAll('svg');
264
+ let bestSvg: Element | null = null;
265
+ let bestScore = -1;
266
+
267
+ for (const svg of svgs) {
268
+ const rect = svg.getBoundingClientRect();
269
+ // 发送按钮应该在输入框右侧,且大小适中
270
+ if (
271
+ rect.width > 10 && rect.width < 80 &&
272
+ rect.height > 10 && rect.height < 80 &&
273
+ Math.abs(rect.y - editorRect.y) < 150 &&
274
+ rect.x > editorRect.x
275
+ ) {
276
+ // 计算分数:距离输入框越近、尺寸越大越好
277
+ const dx = rect.x - (editorRect.x + editorRect.width);
278
+ const dy = Math.abs(rect.y - editorRect.y);
279
+ const size = rect.width + rect.height;
280
+
281
+ // 优先找距离较远(在右侧)且较大的图标
282
+ const score = size * 2 - (dx + dy) * 0.1;
283
+
284
+ if (score > bestScore) {
285
+ bestScore = score;
286
+ bestSvg = svg;
287
+ }
288
+ }
289
+ }
290
+
291
+ if (bestSvg) {
292
+ const parent = bestSvg.parentElement;
293
+ if (parent) {
294
+ (parent as HTMLElement).click();
295
+ return 'clicked';
296
+ }
297
+ }
298
+
299
+ // 备选:按 Enter 键
300
+ return 'not_found';
301
+ });
302
+
303
+ if (sent === 'editor_not_found') {
304
+ console.log(' [send] ⚠ 未找到输入框');
305
+ return false;
306
+ }
307
+
308
+ if (sent === 'not_found') {
309
+ // 用 Enter 键发送
310
+ await page.keyboard.press('Enter');
311
+ console.log(' [send] 使用 Enter 键发送');
312
+ return true;
313
+ }
314
+
315
+ console.log(' [send] ✓ 已点击发送按钮');
316
+ return true;
317
+ }
318
+
319
+ /** 等待 AI 回复并提取文本 */
320
+ async function waitForResponse(page: Page, query: string, maxWaitMs: number = 60000): Promise<string> {
321
+ const startTime = Date.now();
322
+ let lastCandidateCount = 0;
323
+ let lastCandidateText = '';
324
+ let hasQueryInPage = false;
325
+
326
+ while (Date.now() - startTime < maxWaitMs) {
327
+ await page.waitForTimeout(2000);
328
+ try {
329
+ const result = await page.evaluate((q: string) => {
330
+ const pageTxt = document.body?.textContent || '';
331
+
332
+ // 检查查询是否已在页面中
333
+ const hasQuery = pageTxt.includes(q);
334
+
335
+ // 查找可能的回复内容
336
+ const allDivs = document.querySelectorAll('div');
337
+ const candidates: Array<{ text: string; y: number; className: string }> = [];
338
+
339
+ for (let i = allDivs.length - 1; i >= Math.max(0, allDivs.length - 150); i--) {
340
+ const div = allDivs[i];
341
+ const txt = div.textContent?.trim() || '';
342
+ // 排除:输入框占位符、页面底部版权信息、导航
343
+ if (
344
+ txt.length > 15 &&
345
+ !txt.includes('结果由 AI 大模型生成') &&
346
+ !txt.includes('想来知乎工作') &&
347
+ !txt.includes('用户协议') &&
348
+ !txt.includes('隐私政策') &&
349
+ !txt.includes('备案号') &&
350
+ !txt.includes('输入你的问题,或使用') &&
351
+ div.offsetParent !== null
352
+ ) {
353
+ const rect = div.getBoundingClientRect();
354
+ // 过滤掉页面顶部和底部的内容(y 坐标)
355
+ if (rect.y > 100 && rect.y < window.innerHeight - 100) {
356
+ candidates.push({
357
+ text: txt.slice(0, 1000),
358
+ y: rect.y,
359
+ className: div.className,
360
+ });
361
+ }
362
+ }
363
+ }
364
+
365
+ // 按 y 坐标排序(从上到下)
366
+ candidates.sort((a, b) => a.y - b.y);
367
+
368
+ // 过滤掉可能是导航/菜单/选项的内容
369
+ const meaningfulCandidates = candidates.filter(c =>
370
+ !c.text.includes('智能思考') &&
371
+ !c.text.includes('智能决策') &&
372
+ !c.text.includes('深度思考') &&
373
+ !c.text.includes('快速回答') &&
374
+ !c.text.includes('跳过推理直达结果') &&
375
+ !c.text.includes('知识库') &&
376
+ !c.text.includes('推荐') &&
377
+ c.text.length > 20
378
+ );
379
+
380
+ return {
381
+ hasQuery,
382
+ candidateCount: meaningfulCandidates.length,
383
+ candidates: meaningfulCandidates.map(c => c.text),
384
+ };
385
+ }, query);
386
+
387
+ // 查询出现在页面中,说明输入成功
388
+ if (result.hasQuery) {
389
+ hasQueryInPage = true;
390
+ }
391
+
392
+ // 如果候选数量增加,说明有新内容
393
+ if (result.candidateCount > lastCandidateCount) {
394
+ console.log(` [wait] 找到 ${result.candidateCount} 个候选回复`);
395
+ }
396
+
397
+ // 检查是否有新的或更长的内容
398
+ if (result.candidates.length > 0) {
399
+ const longest = result.candidates.reduce((a, b) => (a.length > b.length ? a : b));
400
+
401
+ // 如果内容变化,返回最长的
402
+ if (longest !== lastCandidateText) {
403
+ lastCandidateText = longest;
404
+
405
+ // 如果查询已在页面,且找到了不同的内容,返回
406
+ if (hasQueryInPage && !longest.includes(query)) {
407
+ return longest;
408
+ }
409
+ }
410
+ }
411
+
412
+ lastCandidateCount = result.candidateCount;
413
+ } catch {
414
+ // ignore errors during polling
415
+ }
416
+ }
417
+
418
+ // 返回最后找到的内容
419
+ return lastCandidateText;
420
+ }
421
+
422
+ /** 从回复中提取引用来源 URL */
423
+ async function extractSources(page: Page): Promise<{ total: number; domains: string[]; urls: Array<{ url: string; domain: string }> }> {
424
+ const links = await page.evaluate(() => {
425
+ const seen = new Set<string>();
426
+ return Array.from(document.querySelectorAll('a[href*="http"]'))
427
+ .map(a => ({ href: a.getAttribute('href') || '', text: a.textContent?.trim()?.slice(0, 100) || '' }))
428
+ .filter(item => {
429
+ if (!item.href || seen.has(item.href)) return false;
430
+ seen.add(item.href);
431
+ // 过滤掉知乎内部导航链接
432
+ if (item.href.includes('zhihu.com/question') || item.href.includes('zhida.zhihu.com')) return false;
433
+ return true;
434
+ });
435
+ });
436
+
437
+ const domains = new Set<string>();
438
+ const urls = links.map(l => {
439
+ try {
440
+ const u = new URL(l.href);
441
+ const domain = u.hostname.replace(/^www\./, '');
442
+ domains.add(domain);
443
+ return { url: l.href.slice(0, 300), domain };
444
+ } catch {
445
+ return { url: l.href.slice(0, 300), domain: '' };
446
+ }
447
+ });
448
+
449
+ return {
450
+ total: urls.length,
451
+ domains: Array.from(domains).sort(),
452
+ urls,
453
+ };
454
+ }
455
+
456
+ async function dismissModals(page: Page): Promise<void> {
457
+ await page.evaluate(() => {
458
+ document.querySelectorAll('.Modal-closeButton, [class*="close"], [class*="Close"]').forEach((el) => {
459
+ if (el instanceof HTMLElement) el.click();
460
+ });
461
+ });
462
+ }
463
+
464
+ export default function (xcli: XCLIAPI): void {
465
+ const site = xcli.createSite({
466
+ name: 'zhihu',
467
+ url: 'https://www.zhihu.com',
468
+ description: '知乎 - 知识问答与内容采集 (DA 93)',
469
+ requiresLogin: false,
470
+ });
471
+
472
+ site.command('search', {
473
+ description: '搜索知乎问题、回答、文章',
474
+ scope: 'browser',
475
+ parameters: z.object({
476
+ query: z.string().describe('搜索关键词'),
477
+ type: z.enum(['all', 'question', 'article', 'answer']).optional().default('all'),
478
+ limit: z.number().optional().default(10),
479
+ }),
480
+ examples: [
481
+ { cmd: 'xbrowser zhihu search --query "AI 编程"', description: '搜索 AI 编程相关内容' },
482
+ ],
483
+ result: z.any(),
484
+ handler: async (params, ctx) => {
485
+ const { page, tips } = resolvePage(ctx as Record<string, unknown>);
486
+
487
+ try {
488
+ const searchUrl = `https://www.zhihu.com/search?type=${params.type}&q=${encodeURIComponent(params.query)}`;
489
+ await page.goto(searchUrl, { waitUntil: 'domcontentloaded' });
490
+ await page.waitForTimeout(2000);
491
+ await dismissModals(page);
492
+
493
+ const results = await page.evaluate((limit) => {
494
+ const items: Array<{title: string; excerpt: string; author: string; link: string; type: string}> = [];
495
+ const cards = document.querySelectorAll('.SearchResult-Card, .List-item, [class*="SearchResult"]');
496
+ cards.forEach((card, i) => {
497
+ if (i >= limit) return;
498
+ const titleEl = card.querySelector('h2 a, .ContentItem-title a, a[data-za-detail-view-path-module]');
499
+ const excerptEl = card.querySelector('.content, .RichContent-inner, span.RichText');
500
+ const authorEl = card.querySelector('.AuthorInfo-name, .UserLink-link');
501
+ items.push({
502
+ title: titleEl?.textContent?.trim() || '',
503
+ excerpt: excerptEl?.textContent?.trim()?.slice(0, 200) || '',
504
+ author: authorEl?.textContent?.trim() || '',
505
+ link: titleEl instanceof HTMLAnchorElement ? titleEl.href : '',
506
+ type: card.querySelector('[class*="Question"]') ? 'question' :
507
+ card.querySelector('[class*="Article"]') ? 'article' : 'answer',
508
+ });
509
+ });
510
+ return items;
511
+ }, params.limit);
512
+
513
+ return ok({ query: params.query, count: results.length, results }, [...tips, `找到 ${results.length} 条结果`]);
514
+ } catch (error) {
515
+ return {
516
+ data: null,
517
+ tips,
518
+ message: error instanceof Error ? error.message : '未知错误',
519
+ };
520
+ }
521
+ },
522
+ });
523
+
524
+ site.command('trending', {
525
+ description: '获取知乎热榜',
526
+ scope: 'browser',
527
+ parameters: z.object({
528
+ limit: z.number().optional().default(20),
529
+ }),
530
+ examples: [
531
+ { cmd: 'xbrowser zhihu trending', description: '获取知乎热榜前 20' },
532
+ ],
533
+ result: z.any(),
534
+ handler: async (params, ctx) => {
535
+ const { page, tips } = resolvePage(ctx as Record<string, unknown>);
536
+
537
+ try {
538
+ await page.goto('https://www.zhihu.com/hot', { waitUntil: 'domcontentloaded' });
539
+ await page.waitForTimeout(2000);
540
+ await dismissModals(page);
541
+
542
+ const items = await page.evaluate((limit) => {
543
+ const results: Array<{rank: number; title: string; hotScore: string; link: string}> = [];
544
+ const hotItems = document.querySelectorAll('.HotList-list .HotItem, [class*="HotItem"]');
545
+ hotItems.forEach((item, i) => {
546
+ if (i >= limit) return;
547
+ const titleEl = item.querySelector('.HotItem-title, .HotItem-content .title, [class*="title"]');
548
+ const scoreEl = item.querySelector('.HotItem-metrics, .HotItem-content .metrics, [class*="metrics"]');
549
+ const linkEl = item.querySelector('a');
550
+ results.push({
551
+ rank: i + 1,
552
+ title: titleEl?.textContent?.trim() || '',
553
+ hotScore: scoreEl?.textContent?.trim() || '',
554
+ link: linkEl instanceof HTMLAnchorElement ? linkEl.href : '',
555
+ });
556
+ });
557
+ return results;
558
+ }, params.limit);
559
+
560
+ return ok({ count: items.length, items }, [...tips, `热榜 ${items.length} 条`]);
561
+ } catch (error) {
562
+ return {
563
+ data: null,
564
+ tips,
565
+ message: error instanceof Error ? error.message : '未知错误',
566
+ };
567
+ }
568
+ },
569
+ });
570
+
571
+ site.command('question', {
572
+ description: '获取知乎问题及其回答',
573
+ scope: 'browser',
574
+ parameters: z.object({
575
+ url: z.string().describe('知乎问题 URL'),
576
+ limit: z.number().optional().default(5),
577
+ }),
578
+ examples: [
579
+ { cmd: 'xbrowser zhihu question --url "https://www.zhihu.com/question/xxx"', description: '获取问题回答' },
580
+ ],
581
+ result: z.any(),
582
+ handler: async (params, ctx) => {
583
+ const { page, tips } = resolvePage(ctx as Record<string, unknown>);
584
+
585
+ try {
586
+ await page.goto(params.url, { waitUntil: 'domcontentloaded' });
587
+ await page.waitForTimeout(2000);
588
+ await dismissModals(page);
589
+
590
+ const data = await page.evaluate((limit) => {
591
+ const title = document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim() || '';
592
+ const detail = document.querySelector('.QuestionRichText-inner, [class*="QuestionDetail"]')?.textContent?.trim() || '';
593
+ const answers: Array<{author: string; content: string; upvotes: string}> = [];
594
+
595
+ document.querySelectorAll('.AnswerItem, [class*="AnswerCard"], [class*="AnswerItem"]').forEach((item, i) => {
596
+ if (i >= limit) return;
597
+ const authorEl = item.querySelector('.AuthorInfo-name, .UserLink-link');
598
+ const contentEl = item.querySelector('.RichContent-inner, .RichText');
599
+ const upvoteEl = item.querySelector('.VoteButton--up, [class*="VoteButton"]');
600
+ answers.push({
601
+ author: authorEl?.textContent?.trim() || '匿名',
602
+ content: contentEl?.textContent?.trim()?.slice(0, 500) || '',
603
+ upvotes: upvoteEl?.textContent?.trim() || '0',
604
+ });
605
+ });
606
+
607
+ return { title, detail, answers };
608
+ }, params.limit);
609
+
610
+ return {
611
+ data,
612
+ tips: [...tips, `问题: ${data.title}`, `${data.answers.length} 条回答`],
613
+ };
614
+ } catch (error) {
615
+ return {
616
+ data: null,
617
+ tips,
618
+ message: error instanceof Error ? error.message : '未知错误',
619
+ };
620
+ }
621
+ },
622
+ });
623
+
624
+ site.command('answer', {
625
+ description: '回答知乎问题(支持外链)',
626
+ scope: 'browser',
627
+ parameters: z.object({
628
+ url: z.string().describe('知乎问题 URL'),
629
+ content: z.string().describe('回答内容(Markdown)'),
630
+ }),
631
+ examples: [
632
+ {
633
+ cmd: 'xbrowser zhihu answer --url "https://www.zhihu.com/question/xxx" --content "推荐使用 [XXX](https://example.com)"',
634
+ description: '回答问题并附带外链',
635
+ },
636
+ ],
637
+ result: z.any(),
638
+ handler: async (params, ctx) => {
639
+ const { page, tips } = resolvePage(ctx as Record<string, unknown>);
640
+
641
+ try {
642
+ await page.goto(params.url, { waitUntil: 'domcontentloaded' });
643
+ await page.waitForTimeout(2000);
644
+ await dismissModals(page);
645
+
646
+ const editor = page.locator(
647
+ '.AnswerForm-editor, textarea[placeholder*="写回答"], div[contenteditable="true"][class*="editor"], .ProseMirror, div[contenteditable="true"]'
648
+ ).first();
649
+ if (await editor.isVisible().catch(() => false)) {
650
+ await editor.click();
651
+ await page.waitForTimeout(500);
652
+ await page.keyboard.insertText(params.content);
653
+ }
654
+
655
+ await ctx.waitForHuman?.({
656
+ reason: '检查回答内容后点击提交',
657
+ timeout: 120,
658
+ autoDetect: true,
659
+ });
660
+
661
+ const submitBtn = page.locator(
662
+ 'button:has-text("提交回答"), button:has-text("发布"), button[class*="submit"]'
663
+ ).first();
664
+ if (await submitBtn.isVisible().catch(() => false)) {
665
+ await submitBtn.click();
666
+ await page.waitForTimeout(3000);
667
+ }
668
+
669
+ return ok({ url: params.url, submitted: true, pageUrl: page.url() }, [...tips, '回答已提交']);
670
+ } catch (error) {
671
+ return {
672
+ data: null,
673
+ tips,
674
+ message: error instanceof Error ? error.message : '未知错误',
675
+ };
676
+ }
677
+ },
678
+ });
679
+
680
+ // ====== AI 知答 (zhida.zhihu.com) ======
681
+
682
+ site.command('chat', {
683
+ description: '知乎知答 AI 搜索 — 支持思考模式选择和知识来源过滤,返回 AI 回复及引用来源',
684
+ scope: 'browser',
685
+ parameters: z.object({
686
+ query: z.string().describe('问题或查询内容'),
687
+ mode: z.enum(['smart', 'deep', 'fast']).optional().default('smart')
688
+ .describe('思考模式: smart=智能思考(默认), deep=深度思考, fast=快速回答'),
689
+ source: z.enum(['all', 'zhihu', 'academic', 'my']).optional().default('all')
690
+ .describe('知识来源: all=全网(默认), zhihu=知乎, academic=学术, my=我的知识库'),
691
+ showSources: z.boolean().optional().describe('显示引用来源 URL 和域名统计'),
692
+ }),
693
+ examples: [
694
+ { cmd: 'xbrowser zhihu chat --query "适合编程初学者看的书有哪些?"', description: 'AI 搜索(默认智能思考+全网)' },
695
+ { cmd: 'xbrowser zhihu chat --query "量子计算原理" --mode deep --source academic', description: '深度思考+学术来源' },
696
+ { cmd: 'xbrowser zhihu chat --query "2024年房价走势" --mode fast --showSources', description: '快速回答+显示来源' },
697
+ { cmd: 'xbrowser zhihu chat --query "我的收藏里关于Python的内容" --source my', description: '搜索个人知识库' },
698
+ ],
699
+ result: z.any(),
700
+ handler: async (params, ctx) => {
701
+ try {
702
+ const { page, tips } = resolvePage(ctx as Record<string, unknown>);
703
+
704
+ // 1. 导航到知乎知答页面
705
+ await ensureZhidaPage(page, ctx);
706
+ tips.push(`已打开知乎知答`);
707
+
708
+ // 1.5. 点击"新对话"按钮,清除历史
709
+ const newConversationClicked = await page.evaluate(() => {
710
+ const buttons = document.querySelectorAll('button, [role="button"]');
711
+ for (const btn of buttons) {
712
+ const text = btn.textContent?.trim() || '';
713
+ if (text.includes('新对话') || text.includes('New')) {
714
+ (btn as HTMLElement).click();
715
+ return true;
716
+ }
717
+ }
718
+ return false;
719
+ });
720
+ if (newConversationClicked) {
721
+ console.log(' [conv] 已点击"新对话"按钮');
722
+ await page.waitForTimeout(1000);
723
+ }
724
+
725
+ // 2. 选择思考模式
726
+ if (params.mode && params.mode !== 'smart') {
727
+ await selectThinkingMode(page, params.mode);
728
+ }
729
+
730
+ // 3. 选择知识来源
731
+ if (params.source && params.source !== 'all') {
732
+ await selectKnowledgeSource(page, params.source);
733
+ }
734
+
735
+ // 4. 输入查询内容
736
+ await typeInDraftEditor(page, params.query);
737
+ tips.push(`已输入: ${params.query.slice(0, 50)}${params.query.length > 50 ? '...' : ''}`);
738
+
739
+ // 5. 拦截 AI 响应
740
+ let aiResponse = '';
741
+ const responsePromise = new Promise<void>(resolve => {
742
+ page.on('response', async (response) => {
743
+ const url = response.url();
744
+ if (url.includes('ai_ingress/stream/completion')) {
745
+ try {
746
+ const body = await response.text();
747
+ aiResponse += body;
748
+ console.log(' [api] 捕获 AI 响应:', body.slice(0, 200));
749
+ } catch {
750
+ // ignore errors
751
+ }
752
+ }
753
+ });
754
+ });
755
+
756
+ // 6. 拦截 API 调用(可选,用于提取来源)
757
+ let capturedStream = '';
758
+ if (params.showSources) {
759
+ await page.route('**/zhida.zhihu.com/**', async (route) => {
760
+ try {
761
+ const resp = await route.fetch();
762
+ const body = await resp.text();
763
+ capturedStream += body;
764
+ await route.fulfill({ body, headers: resp.headers(), status: resp.status });
765
+ } catch {
766
+ await route.continue();
767
+ }
768
+ }).catch(() => {});
769
+ }
770
+
771
+ // 7. 点击发送
772
+ await clickSendButton(page);
773
+ tips.push('查询已发送,等待 AI 回复...');
774
+ await page.waitForTimeout(2000);
775
+
776
+ // 8. 等待回复(优先使用拦截的 AI 响应)
777
+ await page.waitForTimeout(5000);
778
+ const responseText = aiResponse || await waitForResponse(page, params.query);
779
+
780
+ // 9. 清理路由拦截
781
+ if (params.showSources) {
782
+ await page.unroute('**/zhida.zhihu.com/**').catch(() => {});
783
+ }
784
+
785
+ // 10. 构建返回结果
786
+ const result: Record<string, unknown> = {
787
+ query: params.query,
788
+ mode: THINKING_MODE_MAP[params.mode] || params.mode,
789
+ source: SOURCE_MAP[params.source] || params.source,
790
+ response: responseText || '等待回复中(可能需要更长时间)',
791
+ };
792
+
793
+ // 10. 提取引用来源(如果请求了)
794
+ if (params.showSources) {
795
+ await new Promise(r => setTimeout(r, 2000));
796
+ let sources;
797
+
798
+ // 先尝试从拦截的流中提取 URL
799
+ if (capturedStream) {
800
+ const urlMatches = capturedStream.match(/https?:\/\/[^"'\s,<>\\\]\)]+/g) || [];
801
+ const allUrls: string[] = [];
802
+ for (const u of urlMatches) {
803
+ const clean = u.replace(/\\u002F/g, '/').split(/[)\]"'.,;:!?]+$/)[0];
804
+ try { new URL(clean); allUrls.push(clean); } catch { /* ignore */ }
805
+ }
806
+ // 去重并提取域名
807
+ const seen = new Set<string>();
808
+ const uniqueUrls = allUrls.filter(u => { const k = u.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
809
+ const domains = new Set<string>();
810
+ sources = {
811
+ total: uniqueUrls.length,
812
+ domains: Array.from(uniqueUrls.map(u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } })).filter((d, i, arr) => arr.indexOf(d) === i).sort(),
813
+ urls: uniqueUrls.slice(0, 20).map(u => ({ url: u.slice(0, 300), domain: (() => { try { return new URL(u).hostname; } catch { return ''; } })() })),
814
+ };
815
+ } else {
816
+ // 从 DOM 中提取链接
817
+ sources = await extractSources(page);
818
+ }
819
+
820
+ result.sources = sources;
821
+ tips.push(`引用来源:${sources.domains.length} 个域名, ${sources.total} 条链接`);
822
+ }
823
+
824
+ return ok(result, [...tips, responseText ? '✅ AI 回复完成' : '⏱ 查询已发送']);
825
+ } catch (error) {
826
+ return fail('未知错误', ['chat 失败']);
827
+ }
828
+ },
829
+ });
830
+
831
+ site.command('article', {
832
+ description: '在知乎发布文章(含外链)',
833
+ scope: 'browser',
834
+ parameters: z.object({
835
+ title: z.string().describe('文章标题'),
836
+ content: z.string().describe('文章内容'),
837
+ topic: z.string().optional().describe('所属话题'),
838
+ }),
839
+ examples: [
840
+ {
841
+ cmd: 'xbrowser zhihu article --title "前端指南" --content "详见 [官网](https://example.com)" --topic "前端开发"',
842
+ description: '发布带外链的知乎文章',
843
+ },
844
+ ],
845
+ result: z.any(),
846
+ handler: async (params, ctx) => {
847
+ const { page, tips } = resolvePage(ctx as Record<string, unknown>);
848
+
849
+ try {
850
+ await page.goto('https://zhuanlan.zhihu.com/write', {
851
+ waitUntil: 'domcontentloaded',
852
+ timeout: 15000,
853
+ });
854
+ await page.waitForTimeout(2000);
855
+ await dismissModals(page);
856
+
857
+ const titleInput = page.locator(
858
+ 'textarea[placeholder*="标题"], input[placeholder*="标题"], [class*="WriteIndex-titleInput"] textarea'
859
+ ).first();
860
+ if (await titleInput.isVisible().catch(() => false)) {
861
+ await titleInput.fill(params.title);
862
+ }
863
+
864
+ await page.waitForTimeout(500);
865
+
866
+ const editor = page.locator(
867
+ '.ProseMirror, div[contenteditable="true"], textarea[class*="editor"]'
868
+ ).first();
869
+ if (await editor.isVisible().catch(() => false)) {
870
+ await editor.click();
871
+ await page.keyboard.insertText(params.content);
872
+ }
873
+
874
+ if (params.topic) {
875
+ const topicInput = page.locator(
876
+ 'input[placeholder*="话题"], input[placeholder*="topic"]'
877
+ ).first();
878
+ if (await topicInput.isVisible().catch(() => false)) {
879
+ await topicInput.fill(params.topic);
880
+ await page.waitForTimeout(1000);
881
+ const topicOption = page.locator('[class*="topic-item"], [role="option"]').first();
882
+ if (await topicOption.isVisible().catch(() => false)) {
883
+ await topicOption.click();
884
+ }
885
+ }
886
+ }
887
+
888
+ await ctx.waitForHuman?.({
889
+ reason: '检查文章内容后点击发布',
890
+ timeout: 120,
891
+ autoDetect: true,
892
+ });
893
+
894
+ const publishBtn = page.locator(
895
+ 'button:has-text("发布"), button[class*="publish"], button:has-text("发表")'
896
+ ).first();
897
+ if (await publishBtn.isVisible().catch(() => false)) {
898
+ await publishBtn.click();
899
+ await page.waitForTimeout(3000);
900
+ }
901
+
902
+ return ok({ title: params.title, topic: params.topic, url: page.url() }, [...tips, `文章 "${params.title}" 已在知乎发布`]);
903
+ } catch (error) {
904
+ return {
905
+ data: null,
906
+ tips,
907
+ message: error instanceof Error ? error.message : '未知错误',
908
+ };
909
+ }
910
+ },
911
+ });
912
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@xbrowser/zhihu",
3
+ "version": "2.1.0",
4
+ "description": "知乎 - 知识问答、内容采集与 AI 知答 (DA 93)",
5
+ "main": "index.ts",
6
+ "keywords": [
7
+ "xbrowser",
8
+ "xbrowser-plugin",
9
+ "zhihu",
10
+ "search",
11
+ "qa",
12
+ "ai-chat",
13
+ "zhida"
14
+ ],
15
+ "author": "dyyz1993",
16
+ "license": "MIT",
17
+ "xbrowser": {
18
+ "site": "https://www.zhihu.com",
19
+ "requiresLogin": false,
20
+ "commands": [
21
+ "search",
22
+ "trending",
23
+ "question",
24
+ "answer",
25
+ "article",
26
+ "chat"
27
+ ]
28
+ }
29
+ }