@jackwener/opencli 1.7.5 → 1.7.7
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/README.md +22 -10
- package/README.zh-CN.md +18 -9
- package/cli-manifest.json +401 -11
- package/clis/51job/company.js +125 -0
- package/clis/51job/detail.js +108 -0
- package/clis/51job/hot.js +55 -0
- package/clis/51job/search.js +79 -0
- package/clis/51job/utils.js +302 -0
- package/clis/51job/utils.test.js +69 -0
- package/clis/bilibili/video.js +68 -0
- package/clis/bilibili/video.test.js +132 -0
- package/clis/chatgpt/image.js +1 -1
- package/clis/deepseek/ask.js +37 -11
- package/clis/deepseek/ask.test.js +165 -0
- package/clis/deepseek/utils.js +192 -24
- package/clis/deepseek/utils.test.js +145 -0
- package/clis/gemini/image.js +1 -1
- package/clis/instagram/download.js +1 -1
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/twitter/likes.js +3 -2
- package/clis/twitter/search.js +4 -2
- package/clis/twitter/search.test.js +4 -0
- package/clis/twitter/shared.js +35 -2
- package/clis/twitter/shared.test.js +96 -0
- package/clis/twitter/thread.js +3 -1
- package/clis/twitter/timeline.js +3 -2
- package/clis/twitter/tweets.js +219 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/web/read.js +25 -5
- package/clis/web/read.test.js +76 -0
- package/clis/weread/ai-outline.js +170 -0
- package/clis/weread/ai-outline.test.js +83 -0
- package/clis/weread/book.js +57 -44
- package/clis/weread/commands.test.js +24 -0
- package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
- package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
- package/clis/youtube/channel.js +35 -0
- package/dist/src/browser/analyze.d.ts +103 -0
- package/dist/src/browser/analyze.js +230 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +164 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/base-page.d.ts +13 -3
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +23 -5
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +76 -3
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.d.ts +1 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.d.ts +1 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/browser/verify-fixture.d.ts +59 -0
- package/dist/src/browser/verify-fixture.js +213 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +161 -0
- package/dist/src/cli.d.ts +32 -0
- package/dist/src/cli.js +936 -141
- package/dist/src/cli.test.js +1051 -1
- package/dist/src/daemon.d.ts +3 -2
- package/dist/src/daemon.js +16 -4
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +19 -0
- package/dist/src/download/article-download.d.ts +12 -0
- package/dist/src/download/article-download.js +141 -17
- package/dist/src/download/article-download.test.js +196 -0
- package/dist/src/download/index.js +73 -86
- package/dist/src/errors.js +4 -2
- package/dist/src/errors.test.js +13 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/launcher.d.ts +1 -1
- package/dist/src/launcher.js +3 -3
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +1 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/types.d.ts +18 -3
- package/package.json +5 -1
package/clis/deepseek/utils.js
CHANGED
|
@@ -38,31 +38,27 @@ export async function getPageState(page) {
|
|
|
38
38
|
|
|
39
39
|
export async function selectModel(page, modelName) {
|
|
40
40
|
return page.evaluate(`(() => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
return { ok: false };
|
|
41
|
+
var radios = document.querySelectorAll('div[role="radio"]');
|
|
42
|
+
if (radios.length === 0) return { ok: false };
|
|
43
|
+
var isFirst = '${modelName}'.toLowerCase() === 'instant';
|
|
44
|
+
if (!isFirst && radios.length < 2) return { ok: false };
|
|
45
|
+
var target = isFirst ? radios[0] : radios[radios.length - 1];
|
|
46
|
+
var alreadySelected = target.getAttribute('aria-checked') === 'true';
|
|
47
|
+
if (!alreadySelected) target.click();
|
|
48
|
+
return { ok: true, toggled: !alreadySelected };
|
|
51
49
|
})()`);
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
export async function setFeature(page, featureName, enabled) {
|
|
53
|
+
// Match by position: DeepThink is the first toggle, Search is the second
|
|
54
|
+
var index = featureName === 'DeepThink' ? 0 : 1;
|
|
55
55
|
return page.evaluate(`(() => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return { ok: true, toggled: ${enabled} !== isActive };
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return { ok: false };
|
|
56
|
+
var toggles = Array.from(document.querySelectorAll('.ds-toggle-button'));
|
|
57
|
+
var btn = toggles[${index}];
|
|
58
|
+
if (!btn) return { ok: false };
|
|
59
|
+
var isActive = btn.classList.contains('ds-toggle-button--selected');
|
|
60
|
+
if (${enabled} !== isActive) btn.click();
|
|
61
|
+
return { ok: true, toggled: ${enabled} !== isActive };
|
|
66
62
|
})()`);
|
|
67
63
|
}
|
|
68
64
|
|
|
@@ -101,7 +97,35 @@ export async function getBubbleCount(page) {
|
|
|
101
97
|
return count || 0;
|
|
102
98
|
}
|
|
103
99
|
|
|
104
|
-
|
|
100
|
+
// Parse thinking response using text as a fallback when DOM-level extraction
|
|
101
|
+
// is not available. Does NOT split on \n\n — that heuristic silently corrupts
|
|
102
|
+
// multi-paragraph thinking or multi-paragraph answers. Instead, everything
|
|
103
|
+
// after the header is treated as thinking content, and `response` stays empty
|
|
104
|
+
// until the caller provides a DOM-separated answer.
|
|
105
|
+
export function parseThinkingResponse(rawText) {
|
|
106
|
+
if (!rawText) return null;
|
|
107
|
+
|
|
108
|
+
// Match thinking header patterns: "Thought for X seconds" or "已思考(用时 X 秒)"
|
|
109
|
+
const thinkHeaderMatch = rawText.match(/^(Thought for ([\d.]+) seconds?|已思考(用时 ([\d.]+) 秒))\s*/);
|
|
110
|
+
|
|
111
|
+
if (!thinkHeaderMatch) {
|
|
112
|
+
// No thinking section found, return plain response
|
|
113
|
+
return { response: rawText, thinking: null, thinking_time: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const thinkingTime = thinkHeaderMatch[2] || thinkHeaderMatch[3];
|
|
117
|
+
const afterHeader = rawText.slice(thinkHeaderMatch[0].length);
|
|
118
|
+
|
|
119
|
+
// Treat everything after the header as thinking. The response will be
|
|
120
|
+
// populated by the DOM-level extraction in waitForResponse().
|
|
121
|
+
return {
|
|
122
|
+
response: '',
|
|
123
|
+
thinking: afterHeader.trim(),
|
|
124
|
+
thinking_time: thinkingTime,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function waitForResponse(page, baselineCount, prompt, timeoutMs, parseThinking = false) {
|
|
105
129
|
const startTime = Date.now();
|
|
106
130
|
let lastText = '';
|
|
107
131
|
let stableCount = 0;
|
|
@@ -114,7 +138,51 @@ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
|
|
114
138
|
result = await page.evaluate(`(() => {
|
|
115
139
|
const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
|
116
140
|
const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean);
|
|
117
|
-
|
|
141
|
+
var last = texts[texts.length - 1] || '';
|
|
142
|
+
|
|
143
|
+
// DOM-level thinking/response separation.
|
|
144
|
+
// DeepSeek renders thinking in a collapsible container with a
|
|
145
|
+
// distinct class (e.g. .ds-markdown--think or similar) and the
|
|
146
|
+
// final answer in the main .ds-markdown region. By querying
|
|
147
|
+
// these separately we avoid any text-heuristic split.
|
|
148
|
+
var thinkEl = null, answerEl = null, thinkTime = null;
|
|
149
|
+
if (${parseThinking} && bubbles.length > 0) {
|
|
150
|
+
var lastBubble = bubbles[bubbles.length - 1];
|
|
151
|
+
// Thinking container — DeepSeek uses various class names;
|
|
152
|
+
// try common selectors.
|
|
153
|
+
thinkEl = lastBubble.querySelector('.ds-markdown--think')
|
|
154
|
+
|| lastBubble.querySelector('[class*="think"]');
|
|
155
|
+
// Final answer container — the main markdown block that is
|
|
156
|
+
// NOT the thinking section.
|
|
157
|
+
var markdownEls = lastBubble.querySelectorAll('.ds-markdown');
|
|
158
|
+
for (var i = 0; i < markdownEls.length; i++) {
|
|
159
|
+
if (markdownEls[i] !== thinkEl
|
|
160
|
+
&& !(thinkEl && thinkEl.contains(markdownEls[i]))
|
|
161
|
+
&& !markdownEls[i].classList.contains('ds-markdown--think')) {
|
|
162
|
+
answerEl = markdownEls[i];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Thinking time from the toggle/header element
|
|
166
|
+
var timeEl = lastBubble.querySelector('[class*="think"] ~ *')
|
|
167
|
+
|| lastBubble.querySelector('.ds-thinking-header');
|
|
168
|
+
if (!timeEl) {
|
|
169
|
+
// Fallback: parse from raw text header
|
|
170
|
+
var m = last.match(/^(?:Thought for ([\\d.]+) seconds?|已思考(用时 ([\\d.]+) 秒))/);
|
|
171
|
+
if (m) thinkTime = m[1] || m[2];
|
|
172
|
+
} else {
|
|
173
|
+
var tm = (timeEl.textContent || '').match(/([\\d.]+)/);
|
|
174
|
+
if (tm) thinkTime = tm[1];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
count: texts.length,
|
|
180
|
+
last: last,
|
|
181
|
+
// DOM-separated fields (null when not available)
|
|
182
|
+
thinkText: thinkEl ? (thinkEl.innerText || '').trim() : null,
|
|
183
|
+
answerText: answerEl ? (answerEl.innerText || '').trim() : null,
|
|
184
|
+
thinkTime: thinkTime,
|
|
185
|
+
};
|
|
118
186
|
})()`);
|
|
119
187
|
} catch {
|
|
120
188
|
continue;
|
|
@@ -126,7 +194,21 @@ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
|
|
126
194
|
if (candidate && result.count > baselineCount && candidate !== prompt.trim()) {
|
|
127
195
|
if (candidate === lastText) {
|
|
128
196
|
stableCount++;
|
|
129
|
-
if (stableCount >= 3)
|
|
197
|
+
if (stableCount >= 3) {
|
|
198
|
+
if (parseThinking) {
|
|
199
|
+
// Prefer DOM-level separation
|
|
200
|
+
if (result.thinkText != null || result.answerText != null) {
|
|
201
|
+
return {
|
|
202
|
+
thinking: result.thinkText || '',
|
|
203
|
+
response: result.answerText || '',
|
|
204
|
+
thinking_time: result.thinkTime || null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// Fallback to text-header parsing (no \n\n split)
|
|
208
|
+
return parseThinkingResponse(candidate);
|
|
209
|
+
}
|
|
210
|
+
return candidate;
|
|
211
|
+
}
|
|
130
212
|
} else {
|
|
131
213
|
stableCount = 0;
|
|
132
214
|
}
|
|
@@ -134,6 +216,9 @@ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
|
|
134
216
|
}
|
|
135
217
|
}
|
|
136
218
|
|
|
219
|
+
if (parseThinking && lastText) {
|
|
220
|
+
return parseThinkingResponse(lastText);
|
|
221
|
+
}
|
|
137
222
|
return lastText || null;
|
|
138
223
|
}
|
|
139
224
|
|
|
@@ -161,7 +246,6 @@ export async function getConversationList(page) {
|
|
|
161
246
|
if (btn) btn.click();
|
|
162
247
|
}
|
|
163
248
|
})()`);
|
|
164
|
-
// Poll for sidebar history links to render
|
|
165
249
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
166
250
|
await page.wait(2);
|
|
167
251
|
const items = await page.evaluate(`(() => {
|
|
@@ -186,6 +270,90 @@ export async function getConversationList(page) {
|
|
|
186
270
|
return [];
|
|
187
271
|
}
|
|
188
272
|
|
|
273
|
+
async function waitForFilePreview(page, fileName) {
|
|
274
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
275
|
+
await page.wait(2);
|
|
276
|
+
const ready = await page.evaluate(`(() => {
|
|
277
|
+
const name = ${JSON.stringify(fileName)};
|
|
278
|
+
return Array.from(document.querySelectorAll('div'))
|
|
279
|
+
.some((el) => el.children.length === 0 && (el.textContent || '').trim() === name);
|
|
280
|
+
})()`);
|
|
281
|
+
if (ready) return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function sendWithFile(page, filePath, prompt) {
|
|
287
|
+
const fs = await import('node:fs');
|
|
288
|
+
const path = await import('node:path');
|
|
289
|
+
const absPath = path.default.resolve(filePath);
|
|
290
|
+
|
|
291
|
+
if (!fs.default.existsSync(absPath)) {
|
|
292
|
+
return { ok: false, reason: `File not found: ${absPath}` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const stats = fs.default.statSync(absPath);
|
|
296
|
+
if (stats.size > 100 * 1024 * 1024) {
|
|
297
|
+
return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 100 MB` };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const fileName = path.default.basename(absPath);
|
|
301
|
+
|
|
302
|
+
// Collapse sidebar to keep DOM simple for send button matching
|
|
303
|
+
await page.evaluate(`(() => {
|
|
304
|
+
if (document.querySelectorAll('a[href*="/a/chat/s/"]').length > 0) {
|
|
305
|
+
const btn = document.querySelector('div[tabindex="0"][role="button"]');
|
|
306
|
+
if (btn) btn.click();
|
|
307
|
+
}
|
|
308
|
+
})()`);
|
|
309
|
+
await page.wait(0.5);
|
|
310
|
+
|
|
311
|
+
let uploaded = false;
|
|
312
|
+
if (page.setFileInput) {
|
|
313
|
+
try {
|
|
314
|
+
await page.setFileInput([absPath], 'input[type="file"]');
|
|
315
|
+
uploaded = true;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const msg = String(err?.message || err);
|
|
318
|
+
if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
|
|
319
|
+
throw err;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!uploaded) {
|
|
325
|
+
const content = fs.default.readFileSync(absPath);
|
|
326
|
+
const base64 = content.toString('base64');
|
|
327
|
+
const fallbackResult = await page.evaluate(`(async () => {
|
|
328
|
+
var binary = atob('${base64}');
|
|
329
|
+
var bytes = new Uint8Array(binary.length);
|
|
330
|
+
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
331
|
+
|
|
332
|
+
var file = new File([bytes], ${JSON.stringify(fileName)});
|
|
333
|
+
var dt = new DataTransfer();
|
|
334
|
+
dt.items.add(file);
|
|
335
|
+
|
|
336
|
+
var inp = document.querySelector('input[type="file"]');
|
|
337
|
+
if (!inp) return { ok: false, reason: 'file input not found' };
|
|
338
|
+
|
|
339
|
+
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
|
|
340
|
+
if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
|
|
341
|
+
return { ok: false, reason: 'React onChange not found' };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
inp.files = dt.files;
|
|
345
|
+
inp[propsKey].onChange({ target: { files: dt.files } });
|
|
346
|
+
return { ok: true };
|
|
347
|
+
})()`);
|
|
348
|
+
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const ready = await waitForFilePreview(page, fileName);
|
|
352
|
+
if (!ready) return { ok: false, reason: 'file preview did not appear' };
|
|
353
|
+
|
|
354
|
+
return sendMessage(page, prompt);
|
|
355
|
+
}
|
|
356
|
+
|
|
189
357
|
// Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions.
|
|
190
358
|
export async function withRetry(fn, retries = 2) {
|
|
191
359
|
for (let i = 0; i <= retries; i++) {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { selectModel, sendWithFile, parseThinkingResponse } from './utils.js';
|
|
6
|
+
|
|
7
|
+
describe('deepseek parseThinkingResponse', () => {
|
|
8
|
+
it('returns plain response when no thinking header is present', () => {
|
|
9
|
+
const rawText = 'This is a regular response without thinking.';
|
|
10
|
+
const result = parseThinkingResponse(rawText);
|
|
11
|
+
|
|
12
|
+
expect(result).toEqual({
|
|
13
|
+
response: rawText,
|
|
14
|
+
thinking: null,
|
|
15
|
+
thinking_time: null,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('parses English thinking header — all content after header is thinking', () => {
|
|
20
|
+
const rawText = 'Thought for 3.5 seconds\n\nLet me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.';
|
|
21
|
+
const result = parseThinkingResponse(rawText);
|
|
22
|
+
|
|
23
|
+
// Text-level parser no longer splits on \n\n; everything after header is thinking.
|
|
24
|
+
// DOM-level extraction in waitForResponse() handles the actual separation.
|
|
25
|
+
expect(result).toEqual({
|
|
26
|
+
response: '',
|
|
27
|
+
thinking: 'Let me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.',
|
|
28
|
+
thinking_time: '3.5',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('parses Chinese thinking header — all content after header is thinking', () => {
|
|
33
|
+
const rawText = '已思考(用时 2.3 秒)\n\n让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。';
|
|
34
|
+
const result = parseThinkingResponse(rawText);
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
response: '',
|
|
38
|
+
thinking: '让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。',
|
|
39
|
+
thinking_time: '2.3',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('multi-paragraph thinking without final answer is not corrupted', () => {
|
|
44
|
+
const rawText = 'Thought for 1.2 seconds\n\nFirst paragraph.\n\nSecond paragraph.';
|
|
45
|
+
const result = parseThinkingResponse(rawText);
|
|
46
|
+
|
|
47
|
+
// Both paragraphs must stay in thinking; response is empty.
|
|
48
|
+
expect(result).toEqual({
|
|
49
|
+
response: '',
|
|
50
|
+
thinking: 'First paragraph.\n\nSecond paragraph.',
|
|
51
|
+
thinking_time: '1.2',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('multi-paragraph final answer is not split by text parser', () => {
|
|
56
|
+
const rawText = 'Thought for 3 seconds\n\nreasoning\n\nAnswer para 1.\n\nAnswer para 2.';
|
|
57
|
+
const result = parseThinkingResponse(rawText);
|
|
58
|
+
|
|
59
|
+
// Text parser treats everything as thinking; DOM handles separation.
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
response: '',
|
|
62
|
+
thinking: 'reasoning\n\nAnswer para 1.\n\nAnswer para 2.',
|
|
63
|
+
thinking_time: '3',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('handles thinking without final response', () => {
|
|
68
|
+
const rawText = 'Thought for 1.2 seconds\n\nThinking process here...';
|
|
69
|
+
const result = parseThinkingResponse(rawText);
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual({
|
|
72
|
+
response: '',
|
|
73
|
+
thinking: 'Thinking process here...',
|
|
74
|
+
thinking_time: '1.2',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns null for empty input', () => {
|
|
79
|
+
const result = parseThinkingResponse('');
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns null for null input', () => {
|
|
84
|
+
const result = parseThinkingResponse(null);
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
describe('deepseek sendWithFile', () => {
|
|
91
|
+
const tempDirs = [];
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
vi.restoreAllMocks();
|
|
95
|
+
while (tempDirs.length) {
|
|
96
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('prefers page.setFileInput over base64-in-evaluate when supported', async () => {
|
|
101
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
|
102
|
+
tempDirs.push(dir);
|
|
103
|
+
const filePath = path.join(dir, 'report.txt');
|
|
104
|
+
fs.writeFileSync(filePath, 'hello');
|
|
105
|
+
|
|
106
|
+
const page = {
|
|
107
|
+
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
108
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
109
|
+
evaluate: vi.fn()
|
|
110
|
+
.mockResolvedValueOnce(undefined)
|
|
111
|
+
.mockResolvedValueOnce(true)
|
|
112
|
+
.mockResolvedValueOnce({ ok: true }),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const result = await sendWithFile(page, filePath, 'summarize this');
|
|
116
|
+
|
|
117
|
+
expect(result).toEqual({ ok: true });
|
|
118
|
+
expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('deepseek selectModel', () => {
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
vi.restoreAllMocks();
|
|
125
|
+
delete global.document;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('fails expert selection when only one radio is present', async () => {
|
|
129
|
+
const instantRadio = {
|
|
130
|
+
getAttribute: vi.fn(() => 'true'),
|
|
131
|
+
click: vi.fn(),
|
|
132
|
+
};
|
|
133
|
+
global.document = {
|
|
134
|
+
querySelectorAll: vi.fn(() => [instantRadio]),
|
|
135
|
+
};
|
|
136
|
+
const page = {
|
|
137
|
+
evaluate: vi.fn(async (script) => eval(script)),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const result = await selectModel(page, 'expert');
|
|
141
|
+
|
|
142
|
+
expect(result).toEqual({ ok: false });
|
|
143
|
+
expect(instantRadio.click).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
});
|
package/clis/gemini/image.js
CHANGED
|
@@ -57,7 +57,7 @@ export const imageCommand = cli({
|
|
|
57
57
|
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to Gemini' },
|
|
58
58
|
{ name: 'rt', default: '1:1', help: 'Ratio shorthand for aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3)' },
|
|
59
59
|
{ name: 'st', default: '', help: 'Style shorthand, e.g. anime, icon, watercolor' },
|
|
60
|
-
{ name: 'op', default:
|
|
60
|
+
{ name: 'op', default: '~/tmp/gemini-images', help: 'Output directory shorthand' },
|
|
61
61
|
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show Gemini page link' },
|
|
62
62
|
],
|
|
63
63
|
columns: ['status', 'file', 'link'],
|
|
@@ -202,7 +202,7 @@ cli({
|
|
|
202
202
|
navigateBefore: false,
|
|
203
203
|
args: [
|
|
204
204
|
{ name: 'url', positional: true, required: true, help: 'Instagram post / reel / tv URL' },
|
|
205
|
-
{ name: 'path', default:
|
|
205
|
+
{ name: 'path', default: '~/Downloads/Instagram', help: 'Download directory' },
|
|
206
206
|
],
|
|
207
207
|
func: async (page, kwargs) => {
|
|
208
208
|
const browserPage = ensurePage(page);
|
package/clis/jianyu/search.js
CHANGED
|
@@ -35,6 +35,10 @@ const NAVIGATION_PATH_PREFIXES = [
|
|
|
35
35
|
'/exhibition/',
|
|
36
36
|
'/swordfish/page_big_pc/search/',
|
|
37
37
|
];
|
|
38
|
+
const BLOCKED_DETAIL_PATH_PREFIXES = [
|
|
39
|
+
'/nologin/content/',
|
|
40
|
+
'/article/bdprivate/',
|
|
41
|
+
];
|
|
38
42
|
const JIANYU_API_TYPES = ['fType', 'eType', 'vType', 'mType'];
|
|
39
43
|
export function buildSearchUrl(query) {
|
|
40
44
|
const url = new URL(SEARCH_ENTRY);
|
|
@@ -74,6 +78,86 @@ function isLikelyNavigationUrl(rawUrl) {
|
|
|
74
78
|
return true;
|
|
75
79
|
}
|
|
76
80
|
}
|
|
81
|
+
function classifyDetailStatus(rawUrl) {
|
|
82
|
+
const urlText = cleanText(rawUrl);
|
|
83
|
+
if (!urlText) {
|
|
84
|
+
return {
|
|
85
|
+
detail_status: 'blocked',
|
|
86
|
+
detail_reason: 'missing_url',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(urlText);
|
|
91
|
+
const path = cleanText(parsed.pathname).toLowerCase().replace(/\/+$/, '/') || '/';
|
|
92
|
+
if (BLOCKED_DETAIL_PATH_PREFIXES.some((prefix) => path.includes(prefix))) {
|
|
93
|
+
return {
|
|
94
|
+
detail_status: 'blocked',
|
|
95
|
+
detail_reason: 'verification_or_paid_wall',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (isLikelyNavigationUrl(urlText)) {
|
|
99
|
+
return {
|
|
100
|
+
detail_status: 'entry_only',
|
|
101
|
+
detail_reason: 'navigation_or_profile_entry',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
detail_status: 'ok',
|
|
106
|
+
detail_reason: path.includes('/jybx/') ? 'jybx_detail' : 'detail_candidate',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {
|
|
111
|
+
detail_status: 'blocked',
|
|
112
|
+
detail_reason: 'invalid_url',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function extractNoticeId(rawUrl) {
|
|
117
|
+
const value = cleanText(rawUrl);
|
|
118
|
+
if (!value)
|
|
119
|
+
return '';
|
|
120
|
+
try {
|
|
121
|
+
const parsed = new URL(value);
|
|
122
|
+
const path = cleanText(parsed.pathname);
|
|
123
|
+
const jybxMatched = path.match(/\/jybx\/([^/?#]+)\.html$/i);
|
|
124
|
+
if (jybxMatched?.[1])
|
|
125
|
+
return cleanText(jybxMatched[1]);
|
|
126
|
+
const segments = path.split('/').filter(Boolean);
|
|
127
|
+
const tail = cleanText(segments[segments.length - 1] || '');
|
|
128
|
+
return cleanText(tail.replace(/\.html?$/i, ''));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function isWithinSinceDays(dateText, sinceDays, now = new Date()) {
|
|
135
|
+
const normalized = normalizeDate(dateText);
|
|
136
|
+
if (!normalized)
|
|
137
|
+
return false;
|
|
138
|
+
const timestamp = Date.parse(`${normalized}T00:00:00Z`);
|
|
139
|
+
if (!Number.isFinite(timestamp))
|
|
140
|
+
return false;
|
|
141
|
+
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
142
|
+
const deltaDays = Math.floor((today - timestamp) / (24 * 3600 * 1000));
|
|
143
|
+
return deltaDays >= 0 && deltaDays <= sinceDays;
|
|
144
|
+
}
|
|
145
|
+
function dedupeByNoticeKey(items) {
|
|
146
|
+
const deduped = [];
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
const source = cleanText(item.source_id || '');
|
|
150
|
+
const notice = cleanText(item.notice_id || '');
|
|
151
|
+
const key = source && notice
|
|
152
|
+
? `${source}\t${notice}`
|
|
153
|
+
: `${cleanText(item.title)}\t${cleanText(item.url)}`;
|
|
154
|
+
if (!key || seen.has(key))
|
|
155
|
+
continue;
|
|
156
|
+
seen.add(key);
|
|
157
|
+
deduped.push(item);
|
|
158
|
+
}
|
|
159
|
+
return deduped;
|
|
160
|
+
}
|
|
77
161
|
function filterNavigationRows(query, items) {
|
|
78
162
|
const queryTokens = cleanText(query).split(/\s+/).filter(Boolean).map((token) => token.toLowerCase());
|
|
79
163
|
return items
|
|
@@ -86,6 +170,9 @@ function filterNavigationRows(query, items) {
|
|
|
86
170
|
.filter((item) => {
|
|
87
171
|
if (!item.title || !item.url)
|
|
88
172
|
return false;
|
|
173
|
+
const detailSignal = classifyDetailStatus(item.url);
|
|
174
|
+
if (detailSignal.detail_status !== 'ok')
|
|
175
|
+
return false;
|
|
89
176
|
const haystack = `${item.title} ${item.contextText}`.toLowerCase();
|
|
90
177
|
const hasQuery = queryTokens.length === 0 || queryTokens.some((token) => haystack.includes(token));
|
|
91
178
|
const hasProcurementHint = PROCUREMENT_TITLE_HINT.test(`${item.title} ${item.contextText}`);
|
|
@@ -446,11 +533,16 @@ cli({
|
|
|
446
533
|
args: [
|
|
447
534
|
{ name: 'query', required: true, positional: true, help: 'Search keyword, e.g. "procurement"' },
|
|
448
535
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
|
|
536
|
+
{ name: 'since_days', type: 'int', help: 'Only keep rows published within N days' },
|
|
449
537
|
],
|
|
450
|
-
columns: ['rank', 'content_type', 'title', '
|
|
538
|
+
columns: ['rank', 'content_type', 'title', 'published_at', 'detail_status', 'project_code', 'budget_or_limit', 'url'],
|
|
451
539
|
func: async (page, kwargs) => {
|
|
452
540
|
const query = cleanText(kwargs.query);
|
|
453
541
|
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
|
|
542
|
+
const rawSinceDays = Number(kwargs.since_days);
|
|
543
|
+
const sinceDays = Number.isFinite(rawSinceDays) && rawSinceDays > 0
|
|
544
|
+
? Math.max(1, Math.min(rawSinceDays, 3650))
|
|
545
|
+
: null;
|
|
454
546
|
const apiResult = await fetchJianyuApiRows(page, query, limit);
|
|
455
547
|
const mergedRows = dedupeCandidates(filterNavigationRows(query, apiResult.rows));
|
|
456
548
|
const extractedRows = await searchRowsFromEntries(page, {
|
|
@@ -465,21 +557,61 @@ cli({
|
|
|
465
557
|
const indexedRows = await fetchDuckDuckGoIndexRows(query, limit);
|
|
466
558
|
const filteredIndexedRows = dedupeCandidates(filterNavigationRows(query, indexedRows));
|
|
467
559
|
if (filteredIndexedRows.length > 0) {
|
|
468
|
-
|
|
560
|
+
const records = toProcurementSearchRecords(filteredIndexedRows, {
|
|
469
561
|
site: SITE,
|
|
470
562
|
query,
|
|
471
563
|
limit,
|
|
472
564
|
});
|
|
565
|
+
const enriched = dedupeByNoticeKey(records.map((row) => {
|
|
566
|
+
const detailSignal = classifyDetailStatus(row.url);
|
|
567
|
+
const publishedAt = normalizeDate(row.publish_time || row.date);
|
|
568
|
+
return {
|
|
569
|
+
...row,
|
|
570
|
+
source_id: SITE,
|
|
571
|
+
notice_id: extractNoticeId(row.url),
|
|
572
|
+
published_at: publishedAt,
|
|
573
|
+
detail_status: detailSignal.detail_status,
|
|
574
|
+
detail_reason: detailSignal.detail_reason,
|
|
575
|
+
};
|
|
576
|
+
}))
|
|
577
|
+
.filter((row) => row.detail_status === 'ok')
|
|
578
|
+
.filter((row) => sinceDays == null || isWithinSinceDays(row.published_at, sinceDays))
|
|
579
|
+
.slice(0, limit)
|
|
580
|
+
.map((row, index) => ({
|
|
581
|
+
...row,
|
|
582
|
+
rank: index + 1,
|
|
583
|
+
}));
|
|
584
|
+
return enriched;
|
|
473
585
|
}
|
|
474
586
|
if (apiResult.challenge || await isAuthRequired(page)) {
|
|
475
587
|
throw new AuthRequiredError(DOMAIN, '[taxonomy=selector_drift] site=jianyu command=search blocked by human verification / access challenge');
|
|
476
588
|
}
|
|
477
589
|
}
|
|
478
|
-
|
|
590
|
+
const records = toProcurementSearchRecords(rows, {
|
|
479
591
|
site: SITE,
|
|
480
592
|
query,
|
|
481
593
|
limit,
|
|
482
594
|
});
|
|
595
|
+
const enriched = dedupeByNoticeKey(records.map((row) => {
|
|
596
|
+
const detailSignal = classifyDetailStatus(row.url);
|
|
597
|
+
const publishedAt = normalizeDate(row.publish_time || row.date);
|
|
598
|
+
return {
|
|
599
|
+
...row,
|
|
600
|
+
source_id: SITE,
|
|
601
|
+
notice_id: extractNoticeId(row.url),
|
|
602
|
+
published_at: publishedAt,
|
|
603
|
+
detail_status: detailSignal.detail_status,
|
|
604
|
+
detail_reason: detailSignal.detail_reason,
|
|
605
|
+
};
|
|
606
|
+
}))
|
|
607
|
+
.filter((row) => row.detail_status === 'ok')
|
|
608
|
+
.filter((row) => sinceDays == null || isWithinSinceDays(row.published_at, sinceDays))
|
|
609
|
+
.slice(0, limit)
|
|
610
|
+
.map((row, index) => ({
|
|
611
|
+
...row,
|
|
612
|
+
rank: index + 1,
|
|
613
|
+
}));
|
|
614
|
+
return enriched;
|
|
483
615
|
},
|
|
484
616
|
});
|
|
485
617
|
export const __test__ = {
|
|
@@ -494,4 +626,8 @@ export const __test__ = {
|
|
|
494
626
|
normalizeApiRow,
|
|
495
627
|
fetchJianyuApiRows,
|
|
496
628
|
collectApiRowsFromResponses,
|
|
629
|
+
classifyDetailStatus,
|
|
630
|
+
extractNoticeId,
|
|
631
|
+
isWithinSinceDays,
|
|
632
|
+
dedupeByNoticeKey,
|
|
497
633
|
};
|
|
@@ -125,4 +125,29 @@ describe('jianyu search helpers', () => {
|
|
|
125
125
|
expect(result.rows[0].title).toContain('电梯采购公告');
|
|
126
126
|
expect(result.rows[1].title).toContain('另一条电梯采购公告');
|
|
127
127
|
});
|
|
128
|
+
it('classifies nologin links as blocked detail targets', () => {
|
|
129
|
+
const signal = __test__.classifyDetailStatus('https://www.jianyu360.cn/nologin/content/ABC.html');
|
|
130
|
+
expect(signal.detail_status).toBe('blocked');
|
|
131
|
+
});
|
|
132
|
+
it('classifies accessible detail urls as ok even when they are not jybx paths', () => {
|
|
133
|
+
const signal = __test__.classifyDetailStatus('https://www.jianyu360.cn/notice/detail/123');
|
|
134
|
+
expect(signal.detail_status).toBe('ok');
|
|
135
|
+
expect(signal.detail_reason).toBe('detail_candidate');
|
|
136
|
+
});
|
|
137
|
+
it('classifies list pages as entry_only', () => {
|
|
138
|
+
const signal = __test__.classifyDetailStatus('https://www.jianyu360.cn/list/stype/ZBGG.html');
|
|
139
|
+
expect(signal.detail_status).toBe('entry_only');
|
|
140
|
+
});
|
|
141
|
+
it('extracts stable notice id from jybx urls', () => {
|
|
142
|
+
const id = __test__.extractNoticeId('https://shandong.jianyu360.cn/jybx/20260310_26030938267551.html');
|
|
143
|
+
expect(id).toBe('20260310_26030938267551');
|
|
144
|
+
});
|
|
145
|
+
it('keeps only rows inside recency window', () => {
|
|
146
|
+
const within = __test__.isWithinSinceDays('2026-03-20', 30, new Date('2026-04-09T00:00:00Z'));
|
|
147
|
+
const stale = __test__.isWithinSinceDays('2026-02-01', 30, new Date('2026-04-09T00:00:00Z'));
|
|
148
|
+
const missing = __test__.isWithinSinceDays('', 30, new Date('2026-04-09T00:00:00Z'));
|
|
149
|
+
expect(within).toBe(true);
|
|
150
|
+
expect(stale).toBe(false);
|
|
151
|
+
expect(missing).toBe(false);
|
|
152
|
+
});
|
|
128
153
|
});
|