@jackwener/opencli 1.7.7 → 1.7.8
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/cli-manifest.json +144 -0
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/chatgpt-app/ask.js +3 -19
- package/clis/chatgpt-app/ax.js +132 -1
- package/clis/chatgpt-app/ax.test.js +23 -0
- package/clis/chatgpt-app/send.js +2 -21
- package/clis/deepseek/ask.js +32 -6
- package/clis/deepseek/ask.test.js +104 -3
- package/clis/deepseek/utils.js +5 -6
- package/clis/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- package/clis/sinafinance/stock.js +5 -2
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/toutiao/articles.js +81 -0
- package/clis/toutiao/articles.test.js +23 -0
- package/clis/weixin/create-draft.js +225 -0
- package/clis/weixin/drafts.js +65 -0
- package/clis/weixin/drafts.test.js +65 -0
- package/dist/src/commanderAdapter.js +12 -0
- package/dist/src/commanderAdapter.test.js +11 -0
- package/package.json +1 -1
package/clis/deepseek/ask.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
|
|
3
3
|
import {
|
|
4
4
|
DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
|
|
5
5
|
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
@@ -35,17 +35,43 @@ export const askCommand = cli({
|
|
|
35
35
|
await page.goto(DEEPSEEK_URL);
|
|
36
36
|
await page.wait(3);
|
|
37
37
|
} else {
|
|
38
|
-
await ensureOnDeepSeek(page);
|
|
38
|
+
const navigated = await ensureOnDeepSeek(page);
|
|
39
|
+
if (navigated) {
|
|
40
|
+
// Workspace was recycled; try to resume the most recent
|
|
41
|
+
// conversation instead of starting a new one.
|
|
42
|
+
await page.evaluate(`(() => {
|
|
43
|
+
var link = document.querySelector('a[href*="/a/chat/s/"]');
|
|
44
|
+
if (link) link.click();
|
|
45
|
+
})()`);
|
|
46
|
+
await page.wait(2);
|
|
47
|
+
}
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
await page.wait(2);
|
|
42
51
|
|
|
52
|
+
// Model selector is only available on the new-chat page, not inside
|
|
53
|
+
// an existing conversation. Skip it when we resumed a prior thread.
|
|
54
|
+
const currentUrl = await page.evaluate('window.location.href') || '';
|
|
55
|
+
const inConversation = currentUrl.includes('/a/chat/s/');
|
|
56
|
+
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
|
|
57
|
+
|
|
43
58
|
const wantModel = kwargs.model || 'instant';
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
if (inConversation && modelExplicit) {
|
|
60
|
+
throw new CliError(
|
|
61
|
+
'ARGUMENT',
|
|
62
|
+
`Cannot switch to ${wantModel} model inside an existing conversation.`,
|
|
63
|
+
'Re-run with --new to start a fresh chat before selecting a model.',
|
|
64
|
+
EXIT_CODES.USAGE_ERROR,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!inConversation) {
|
|
69
|
+
const modelResult = await withRetry(() => selectModel(page, wantModel));
|
|
70
|
+
if (!modelResult?.ok) {
|
|
71
|
+
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
|
72
|
+
}
|
|
73
|
+
if (modelResult?.toggled) await page.wait(0.5);
|
|
47
74
|
}
|
|
48
|
-
if (modelResult?.toggled) await page.wait(0.5);
|
|
49
75
|
|
|
50
76
|
const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
|
|
51
77
|
if (!thinkResult?.ok && wantThink) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
|
|
3
3
|
|
|
4
4
|
const {
|
|
5
5
|
mockEnsureOnDeepSeek,
|
|
@@ -43,11 +43,13 @@ describe('deepseek ask --file', () => {
|
|
|
43
43
|
const page = {
|
|
44
44
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
45
45
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
46
|
+
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
beforeEach(() => {
|
|
49
50
|
vi.clearAllMocks();
|
|
50
|
-
|
|
51
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
|
52
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
51
53
|
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
52
54
|
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
53
55
|
mockSendWithFile.mockResolvedValue({ ok: true });
|
|
@@ -90,11 +92,13 @@ describe('deepseek ask --think', () => {
|
|
|
90
92
|
const page = {
|
|
91
93
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
92
94
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
95
|
+
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
|
|
93
96
|
};
|
|
94
97
|
|
|
95
98
|
beforeEach(() => {
|
|
96
99
|
vi.clearAllMocks();
|
|
97
|
-
|
|
100
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
|
101
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
98
102
|
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
99
103
|
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
100
104
|
mockSendMessage.mockResolvedValue({ ok: true });
|
|
@@ -163,3 +167,100 @@ describe('deepseek ask --think', () => {
|
|
|
163
167
|
expect(Object.keys(rows[0])).toEqual(['response']);
|
|
164
168
|
});
|
|
165
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('deepseek ask conversation resume', () => {
|
|
172
|
+
const page = {
|
|
173
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
174
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
175
|
+
evaluate: vi.fn(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
vi.clearAllMocks();
|
|
180
|
+
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
181
|
+
mockSendMessage.mockResolvedValue({ ok: true });
|
|
182
|
+
mockGetBubbleCount.mockResolvedValue(2);
|
|
183
|
+
mockWaitForResponse.mockResolvedValue('follow-up reply');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('resumes the most recent conversation and skips model selection', async () => {
|
|
187
|
+
mockEnsureOnDeepSeek.mockResolvedValue(true);
|
|
188
|
+
// first evaluate: sidebar resume click (returns undefined)
|
|
189
|
+
page.evaluate.mockResolvedValueOnce(undefined);
|
|
190
|
+
// second evaluate: URL check (now inside a conversation)
|
|
191
|
+
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/abc-123');
|
|
192
|
+
|
|
193
|
+
const rows = await askCommand.func(page, {
|
|
194
|
+
prompt: 'follow up',
|
|
195
|
+
timeout: 120,
|
|
196
|
+
new: false,
|
|
197
|
+
model: 'instant',
|
|
198
|
+
think: false,
|
|
199
|
+
search: false,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
|
203
|
+
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
204
|
+
expect(mockSendMessage).toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('skips model selection when already inside an existing conversation', async () => {
|
|
208
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
209
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
|
|
210
|
+
|
|
211
|
+
const rows = await askCommand.func(page, {
|
|
212
|
+
prompt: 'continue',
|
|
213
|
+
timeout: 120,
|
|
214
|
+
new: false,
|
|
215
|
+
model: 'expert',
|
|
216
|
+
think: false,
|
|
217
|
+
search: false,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
|
221
|
+
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('fails fast when --model is explicitly requested inside an existing conversation', async () => {
|
|
225
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
226
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
|
|
227
|
+
|
|
228
|
+
await expect(askCommand.func(page, {
|
|
229
|
+
prompt: 'continue',
|
|
230
|
+
timeout: 120,
|
|
231
|
+
new: false,
|
|
232
|
+
model: 'expert',
|
|
233
|
+
think: false,
|
|
234
|
+
search: false,
|
|
235
|
+
__opencliOptionSources: { model: 'cli' },
|
|
236
|
+
})).rejects.toMatchObject(new CliError(
|
|
237
|
+
'ARGUMENT',
|
|
238
|
+
'Cannot switch to expert model inside an existing conversation.',
|
|
239
|
+
'Re-run with --new to start a fresh chat before selecting a model.',
|
|
240
|
+
EXIT_CODES.USAGE_ERROR,
|
|
241
|
+
));
|
|
242
|
+
|
|
243
|
+
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('still selects model when no conversation to resume', async () => {
|
|
247
|
+
mockEnsureOnDeepSeek.mockResolvedValue(true);
|
|
248
|
+
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
249
|
+
// first evaluate: sidebar resume click (no link found)
|
|
250
|
+
page.evaluate.mockResolvedValueOnce(undefined);
|
|
251
|
+
// second evaluate: URL check (still on root page)
|
|
252
|
+
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/');
|
|
253
|
+
|
|
254
|
+
const rows = await askCommand.func(page, {
|
|
255
|
+
prompt: 'hello',
|
|
256
|
+
timeout: 120,
|
|
257
|
+
new: false,
|
|
258
|
+
model: 'instant',
|
|
259
|
+
think: false,
|
|
260
|
+
search: false,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
|
264
|
+
expect(mockSelectModel).toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
});
|
package/clis/deepseek/utils.js
CHANGED
|
@@ -15,10 +15,10 @@ export async function isOnDeepSeek(page) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export async function ensureOnDeepSeek(page) {
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
if (await isOnDeepSeek(page)) return false;
|
|
19
|
+
await page.goto(DEEPSEEK_URL);
|
|
20
|
+
await page.wait(3);
|
|
21
|
+
return true;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function getPageState(page) {
|
|
@@ -252,8 +252,7 @@ export async function getConversationList(page) {
|
|
|
252
252
|
const items = [];
|
|
253
253
|
const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
|
|
254
254
|
links.forEach((link, i) => {
|
|
255
|
-
const
|
|
256
|
-
const title = titleEl ? titleEl.textContent.trim() : '';
|
|
255
|
+
const title = (link.innerText || '').trim().split('\\n')[0].trim();
|
|
257
256
|
const href = link.getAttribute('href') || '';
|
|
258
257
|
const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
|
|
259
258
|
items.push({
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PowerChina search — browser DOM extraction with multi-entry URL probing.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import { AuthRequiredError } from '@jackwener/opencli/errors';
|
|
6
|
+
import {
|
|
7
|
+
cleanText,
|
|
8
|
+
normalizeDate,
|
|
9
|
+
toProcurementSearchRecords,
|
|
10
|
+
} from '../jianyu/shared/procurement-contract.js';
|
|
11
|
+
import { searchRowsFromEntries } from '../jianyu/shared/china-bid-search.js';
|
|
12
|
+
|
|
13
|
+
const SEARCH_ENTRIES = [
|
|
14
|
+
'https://bid.powerchina.cn/search',
|
|
15
|
+
'https://bid.powerchina.cn/',
|
|
16
|
+
];
|
|
17
|
+
const API_LIST_ENDPOINT = 'https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/list';
|
|
18
|
+
const API_DETAIL_ENDPOINT = 'https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo';
|
|
19
|
+
const API_DEFAULT_ANNOUNCEMENT_TYPE = '招采公告';
|
|
20
|
+
|
|
21
|
+
const PROCUREMENT_TITLE_HINT = /(公告|招标|采购|中标|成交|项目|notice|tender|bidding)/i;
|
|
22
|
+
const NAVIGATION_TITLE_HINT = /^(english|中文|chinese|language|home|首页|搜索|search)$/i;
|
|
23
|
+
const RETRYABLE_SEARCH_ERROR_HINT = /(detached while handling command|execution context was destroyed|target closed|cannot find context with specified id)/i;
|
|
24
|
+
|
|
25
|
+
export function buildSearchCandidates(query) {
|
|
26
|
+
const keyword = query.trim();
|
|
27
|
+
if (!keyword) return [...SEARCH_ENTRIES];
|
|
28
|
+
const encoded = encodeURIComponent(keyword);
|
|
29
|
+
return [
|
|
30
|
+
`https://bid.powerchina.cn/search?keyword=${encoded}`,
|
|
31
|
+
`https://bid.powerchina.cn/search?keywords=${encoded}`,
|
|
32
|
+
`https://bid.powerchina.cn/search?q=${encoded}`,
|
|
33
|
+
...SEARCH_ENTRIES,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dedupeCandidates(items) {
|
|
38
|
+
const deduped = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
const key = `${item.title}\t${item.url}`;
|
|
42
|
+
if (seen.has(key)) continue;
|
|
43
|
+
seen.add(key);
|
|
44
|
+
deduped.push(item);
|
|
45
|
+
}
|
|
46
|
+
return deduped;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isLikelyNavigationUrl(rawUrl) {
|
|
50
|
+
const urlText = cleanText(rawUrl);
|
|
51
|
+
if (!urlText) return true;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = new URL(urlText);
|
|
54
|
+
const pathname = parsed.pathname.toLowerCase().replace(/\/+$/, '') || '/';
|
|
55
|
+
const hash = cleanText(parsed.hash).toLowerCase();
|
|
56
|
+
if (pathname === '/' || pathname === '/index') return true;
|
|
57
|
+
if (pathname === '/search') return true;
|
|
58
|
+
if (pathname === '/old' || pathname.startsWith('/old/')) return true;
|
|
59
|
+
if (pathname === '/en' || pathname.startsWith('/en/')) return true;
|
|
60
|
+
if (pathname === '/zh' || pathname.startsWith('/zh/')) return true;
|
|
61
|
+
if (hash === '#/' || hash === '#/index' || hash.startsWith('#/search')) return true;
|
|
62
|
+
return false;
|
|
63
|
+
} catch {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isLikelyNavigationTitle(rawTitle) {
|
|
69
|
+
const title = cleanText(rawTitle);
|
|
70
|
+
if (!title) return true;
|
|
71
|
+
const normalized = title.toLowerCase();
|
|
72
|
+
if (NAVIGATION_TITLE_HINT.test(normalized)) return true;
|
|
73
|
+
if (normalized.length <= 10 && (normalized === 'en' || normalized === 'zh' || normalized.includes('english'))) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function filterNavigationRows(items) {
|
|
80
|
+
return items.filter((item) => {
|
|
81
|
+
const title = cleanText(item.title);
|
|
82
|
+
const url = cleanText(item.url);
|
|
83
|
+
if (!url || !title) return false;
|
|
84
|
+
if (isLikelyNavigationUrl(url)) return false;
|
|
85
|
+
if (isLikelyNavigationTitle(title) && !PROCUREMENT_TITLE_HINT.test(title)) return false;
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildApiDetailUrl(id) {
|
|
91
|
+
const normalizedId = cleanText(id);
|
|
92
|
+
if (!normalizedId) return '';
|
|
93
|
+
return `${API_DETAIL_ENDPOINT}/${encodeURIComponent(normalizedId)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toApiCandidate(row) {
|
|
97
|
+
const id = cleanText(row.id);
|
|
98
|
+
const title = cleanText(row.title);
|
|
99
|
+
if (!id || !title) return null;
|
|
100
|
+
|
|
101
|
+
const url = buildApiDetailUrl(id);
|
|
102
|
+
if (!url) return null;
|
|
103
|
+
|
|
104
|
+
const contextText = cleanText([
|
|
105
|
+
row.announcementType,
|
|
106
|
+
row.titleTypeName,
|
|
107
|
+
row.source,
|
|
108
|
+
row.publishTime,
|
|
109
|
+
row.registrationDeadline,
|
|
110
|
+
row.submissionDeadline,
|
|
111
|
+
row.bidOpenTime,
|
|
112
|
+
].filter(Boolean).join(' | '));
|
|
113
|
+
|
|
114
|
+
const date = normalizeDate(cleanText(row.publishTime || row.bidOpenTime || row.submissionDeadline || ''));
|
|
115
|
+
return {
|
|
116
|
+
title,
|
|
117
|
+
url,
|
|
118
|
+
date,
|
|
119
|
+
contextText,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function searchRowsFromApi(query, limit) {
|
|
124
|
+
const keyword = cleanText(query);
|
|
125
|
+
const pageSize = Math.max(20, Math.min(100, Math.max(limit * 3, limit)));
|
|
126
|
+
const payload = {
|
|
127
|
+
pageNum: 1,
|
|
128
|
+
pageSize,
|
|
129
|
+
announcementType: API_DEFAULT_ANNOUNCEMENT_TYPE,
|
|
130
|
+
companyType: '3',
|
|
131
|
+
time: Date.now(),
|
|
132
|
+
};
|
|
133
|
+
if (keyword) payload.keyWords = keyword;
|
|
134
|
+
|
|
135
|
+
const response = await fetch(API_LIST_ENDPOINT, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'application/json;charset=utf-8',
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify(payload),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search api HTTP ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const data = await response.json();
|
|
148
|
+
if ((data.code ?? 200) !== 200) {
|
|
149
|
+
throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search api code=${data.code ?? 'unknown'} msg=${cleanText(data.msg)}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const rows = Array.isArray(data.rows) ? data.rows : [];
|
|
153
|
+
const mapped = rows
|
|
154
|
+
.map((row) => toApiCandidate(row))
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
return dedupeCandidates(mapped).slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
cli({
|
|
160
|
+
site: 'powerchina',
|
|
161
|
+
name: 'search',
|
|
162
|
+
description: '搜索中国电建阳光采购公告',
|
|
163
|
+
domain: 'bid.powerchina.cn',
|
|
164
|
+
strategy: Strategy.COOKIE,
|
|
165
|
+
browser: true,
|
|
166
|
+
args: [
|
|
167
|
+
{ name: 'query', required: true, positional: true, help: 'Search keyword, e.g. "procurement"' },
|
|
168
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
|
|
169
|
+
],
|
|
170
|
+
columns: ['rank', 'content_type', 'title', 'publish_time', 'project_code', 'budget_or_limit', 'url'],
|
|
171
|
+
func: async (page, kwargs) => {
|
|
172
|
+
const query = cleanText(kwargs.query);
|
|
173
|
+
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
|
|
174
|
+
let extractedRows = [];
|
|
175
|
+
let apiFailure = null;
|
|
176
|
+
let apiSucceeded = false;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const apiRows = await searchRowsFromApi(query, limit);
|
|
180
|
+
extractedRows = apiRows;
|
|
181
|
+
apiSucceeded = true;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
apiFailure = cleanText(error instanceof Error ? error.message : String(error || ''));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (apiSucceeded && extractedRows.length === 0) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!apiSucceeded) {
|
|
191
|
+
try {
|
|
192
|
+
extractedRows = await searchRowsFromEntries(page, {
|
|
193
|
+
query,
|
|
194
|
+
candidateUrls: buildSearchCandidates(query),
|
|
195
|
+
allowedHostFragments: ['bid.powerchina.cn', 'powerchina.cn'],
|
|
196
|
+
limit,
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const message = cleanText(error instanceof Error ? error.message : String(error || ''));
|
|
200
|
+
if (RETRYABLE_SEARCH_ERROR_HINT.test(message)) {
|
|
201
|
+
throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search detached browser context: ${message}`);
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const rows = filterNavigationRows(
|
|
208
|
+
dedupeCandidates(extractedRows).map((item) => ({
|
|
209
|
+
title: cleanText(item.title),
|
|
210
|
+
url: cleanText(item.url),
|
|
211
|
+
date: normalizeDate(cleanText(item.date)),
|
|
212
|
+
contextText: cleanText(item.contextText),
|
|
213
|
+
})),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (rows.length === 0 && extractedRows.length > 0) {
|
|
217
|
+
throw new Error('[taxonomy=empty_result] site=powerchina command=search extracted only navigation/portal rows');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (rows.length === 0) {
|
|
221
|
+
const pageText = cleanText(await page.evaluate('document.body ? document.body.innerText : ""'));
|
|
222
|
+
if (/(请先登录|未登录|登录后|验证码|人机验证)/.test(pageText)) {
|
|
223
|
+
throw new AuthRequiredError(
|
|
224
|
+
'bid.powerchina.cn',
|
|
225
|
+
'[taxonomy=selector_drift] site=powerchina command=search login required or human verification',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (apiFailure) {
|
|
229
|
+
throw new Error(`[taxonomy=empty_result] site=powerchina command=search api/dom yielded no result: ${apiFailure}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return toProcurementSearchRecords(rows, {
|
|
234
|
+
site: 'powerchina',
|
|
235
|
+
query,
|
|
236
|
+
limit,
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
export const __test__ = {
|
|
242
|
+
buildSearchCandidates,
|
|
243
|
+
normalizeDate,
|
|
244
|
+
dedupeCandidates,
|
|
245
|
+
filterNavigationRows,
|
|
246
|
+
isLikelyNavigationUrl,
|
|
247
|
+
isLikelyNavigationTitle,
|
|
248
|
+
buildApiDetailUrl,
|
|
249
|
+
toApiCandidate,
|
|
250
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './search.js';
|
|
3
|
+
|
|
4
|
+
describe('powerchina search helpers', () => {
|
|
5
|
+
it('builds candidate URLs with keyword variants', () => {
|
|
6
|
+
const candidates = __test__.buildSearchCandidates('procurement');
|
|
7
|
+
expect(candidates[0]).toContain('keyword=procurement');
|
|
8
|
+
expect(candidates.some((item) => item.includes('/search?keywords='))).toBe(true);
|
|
9
|
+
expect(candidates.some((item) => item === 'https://bid.powerchina.cn/search')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('normalizes date text', () => {
|
|
13
|
+
expect(__test__.normalizeDate('2026-4-7')).toBe('2026-04-07');
|
|
14
|
+
expect(__test__.normalizeDate('公告时间:2026年04月07日')).toBe('2026-04-07');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('deduplicates title/url pairs', () => {
|
|
18
|
+
const deduped = __test__.dedupeCandidates([
|
|
19
|
+
{ title: 'A', url: 'https://a.com/1', date: '2026-04-07' },
|
|
20
|
+
{ title: 'A', url: 'https://a.com/1', date: '2026-04-07' },
|
|
21
|
+
{ title: 'B', url: 'https://a.com/1', date: '2026-04-07' },
|
|
22
|
+
]);
|
|
23
|
+
expect(deduped).toHaveLength(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('filters obvious navigation rows before quality gate', () => {
|
|
27
|
+
const filtered = __test__.filterNavigationRows([
|
|
28
|
+
{ title: '搜索', url: 'https://bid.powerchina.cn/search', date: '2026-04-07' },
|
|
29
|
+
{ title: '首页', url: 'https://bid.powerchina.cn/', date: '2026-04-07' },
|
|
30
|
+
{ title: 'English', url: 'https://bid.powerchina.cn/old/en', date: '' },
|
|
31
|
+
{ title: '某项目电梯采购公告', url: 'https://bid.powerchina.cn/notice/detail?id=123', date: '2026-04-07' },
|
|
32
|
+
]);
|
|
33
|
+
expect(filtered).toHaveLength(1);
|
|
34
|
+
expect(filtered[0].title).toContain('电梯采购公告');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('treats old/en language switch urls as navigation', () => {
|
|
38
|
+
expect(__test__.isLikelyNavigationUrl('https://bid.powerchina.cn/old/en')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('treats language-toggle labels as navigation titles', () => {
|
|
42
|
+
expect(__test__.isLikelyNavigationTitle('English')).toBe(true);
|
|
43
|
+
expect(__test__.isLikelyNavigationTitle('EN')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('builds api detail urls with stable id', () => {
|
|
47
|
+
const url = __test__.buildApiDetailUrl('2409419657');
|
|
48
|
+
expect(url).toBe('https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo/2409419657');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('maps api rows into normalized search candidates', () => {
|
|
52
|
+
const mapped = __test__.toApiCandidate({
|
|
53
|
+
id: '2409419657',
|
|
54
|
+
title: '某项目电梯采购公告',
|
|
55
|
+
announcementType: '招采公告',
|
|
56
|
+
companyType: '3',
|
|
57
|
+
titleTypeName: '货物类',
|
|
58
|
+
source: '设备物资集中采购电子平台',
|
|
59
|
+
publishTime: '2026-04-07 17:05:02',
|
|
60
|
+
submissionDeadline: '2026-04-14',
|
|
61
|
+
});
|
|
62
|
+
expect(mapped).not.toBeNull();
|
|
63
|
+
expect(mapped?.title).toContain('电梯采购公告');
|
|
64
|
+
expect(mapped?.date).toBe('2026-04-07');
|
|
65
|
+
expect(mapped?.url).toBe('https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo/2409419657');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -79,12 +79,15 @@ cli({
|
|
|
79
79
|
if (!entries.length) {
|
|
80
80
|
throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market');
|
|
81
81
|
}
|
|
82
|
-
// Pick best match: score by name similarity, tiebreak by market priority
|
|
82
|
+
// Pick best match: score by name/symbol similarity, tiebreak by market priority
|
|
83
83
|
const needle = key.toLowerCase();
|
|
84
84
|
const score = (e) => {
|
|
85
85
|
const n = e.name.toLowerCase();
|
|
86
|
-
|
|
86
|
+
const s = e.symbol.toLowerCase();
|
|
87
|
+
if (s === needle || n === needle)
|
|
87
88
|
return 1;
|
|
89
|
+
if (s.includes(needle))
|
|
90
|
+
return needle.length / s.length;
|
|
88
91
|
if (n.includes(needle))
|
|
89
92
|
return needle.length / n.length;
|
|
90
93
|
return 0;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './stock.js';
|
|
4
|
+
|
|
5
|
+
function textResponse(body) {
|
|
6
|
+
return {
|
|
7
|
+
ok: true,
|
|
8
|
+
arrayBuffer: async () => Buffer.from(body, 'utf8'),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('sinafinance stock command', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
vi.stubGlobal('TextDecoder', class {
|
|
16
|
+
decode(buf) {
|
|
17
|
+
return Buffer.from(buf).toString('utf8');
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('prefers exact symbol match over partial symbol and name misses', async () => {
|
|
23
|
+
const cmd = getRegistry().get('sinafinance/stock');
|
|
24
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
25
|
+
|
|
26
|
+
const fetchMock = vi.fn()
|
|
27
|
+
.mockResolvedValueOnce(textResponse('var suggestvalue="x,41,,AAPL,苹果;x,41,,AAPLU,Apple Units";'))
|
|
28
|
+
.mockResolvedValueOnce(textResponse('var hq_str_gb_AAPL="Apple Inc,189.98,1.23,0,1.56,0,188.50,180.00,195.00,175.00,1200000,0,3000000000000";'));
|
|
29
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
30
|
+
|
|
31
|
+
const result = await cmd.func(null, { key: 'AAPL', market: 'auto' });
|
|
32
|
+
|
|
33
|
+
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://suggest3.sinajs.cn/suggest/type=11,31,41&key=AAPL', expect.any(Object));
|
|
34
|
+
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://hq.sinajs.cn/list=gb_AAPL', expect.any(Object));
|
|
35
|
+
expect(result[0]).toMatchObject({
|
|
36
|
+
Symbol: 'AAPL',
|
|
37
|
+
Name: 'Apple Inc',
|
|
38
|
+
Price: '189.98',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('still matches by display name when the query targets the company name', async () => {
|
|
43
|
+
const cmd = getRegistry().get('sinafinance/stock');
|
|
44
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
45
|
+
|
|
46
|
+
const fetchMock = vi.fn()
|
|
47
|
+
.mockResolvedValueOnce(textResponse('var suggestvalue="x,41,,AAPL,苹果;x,41,,AAPLU,Apple Units";'))
|
|
48
|
+
.mockResolvedValueOnce(textResponse('var hq_str_gb_AAPL="苹果公司,189.98,1.23,0,1.56,0,188.50,180.00,195.00,175.00,1200000,0,3000000000000";'));
|
|
49
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
50
|
+
|
|
51
|
+
const result = await cmd.func(null, { key: '苹果', market: 'auto' });
|
|
52
|
+
|
|
53
|
+
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://hq.sinajs.cn/list=gb_AAPL', expect.any(Object));
|
|
54
|
+
expect(result[0]).toMatchObject({
|
|
55
|
+
Symbol: 'AAPL',
|
|
56
|
+
Name: '苹果公司',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|