@jackwener/opencli 1.7.4 → 1.7.6
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 +76 -51
- package/README.zh-CN.md +78 -62
- package/cli-manifest.json +4558 -2979
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +94 -0
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +291 -0
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- 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/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/download.js +8 -4
- package/clis/xiaoyuzhou/download.test.js +23 -13
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/channel.js +35 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +14 -4
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +13 -4
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/daemon-client.d.ts +6 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +83 -5
- 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.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.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/page.d.ts +14 -4
- package/dist/src/browser/page.js +48 -7
- package/dist/src/browser/page.test.js +97 -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/cli.js +849 -267
- package/dist/src/cli.test.js +961 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +8 -0
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/plugin.d.ts +1 -8
- package/dist/src/plugin.js +1 -27
- package/dist/src/plugin.test.js +1 -59
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +32 -8
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// eastmoney sectors — industry / concept / region sector board ranking.
|
|
2
|
+
//
|
|
3
|
+
// opencli eastmoney sectors
|
|
4
|
+
// opencli eastmoney sectors --type concept --sort money-flow --limit 30
|
|
5
|
+
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { CliError } from '@jackwener/opencli/errors';
|
|
8
|
+
|
|
9
|
+
const SECTOR_TYPES = {
|
|
10
|
+
industry: 'm:90+t:2',
|
|
11
|
+
concept: 'm:90+t:3',
|
|
12
|
+
region: 'm:90+t:1',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SORTS = {
|
|
16
|
+
change: { fid: 'f3', order: 'desc' },
|
|
17
|
+
drop: { fid: 'f3', order: 'asc' },
|
|
18
|
+
'money-flow': { fid: 'f62', order: 'desc' },
|
|
19
|
+
'out-flow': { fid: 'f62', order: 'asc' },
|
|
20
|
+
turnover: { fid: 'f6', order: 'desc' },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
cli({
|
|
24
|
+
site: 'eastmoney',
|
|
25
|
+
name: 'sectors',
|
|
26
|
+
description: '板块排行(行业/概念/地域)按涨跌幅、主力资金或成交额排序',
|
|
27
|
+
domain: 'push2.eastmoney.com',
|
|
28
|
+
strategy: Strategy.PUBLIC,
|
|
29
|
+
browser: false,
|
|
30
|
+
args: [
|
|
31
|
+
{ name: 'type', type: 'string', default: 'industry', help: '板块类型:industry / concept / region' },
|
|
32
|
+
{ name: 'sort', type: 'string', default: 'change', help: '排序:change / drop / money-flow / out-flow / turnover' },
|
|
33
|
+
{ name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
|
|
34
|
+
],
|
|
35
|
+
columns: ['rank', 'code', 'name', 'price', 'changePercent', 'mainNet', 'leadStock', 'leadChangePercent', 'upCount', 'downCount'],
|
|
36
|
+
func: async (_page, args) => {
|
|
37
|
+
const typeKey = String(args.type ?? 'industry').toLowerCase();
|
|
38
|
+
const fs = SECTOR_TYPES[typeKey];
|
|
39
|
+
if (!fs) throw new CliError('INVALID_ARGUMENT', `Unknown sector type "${typeKey}". Valid: ${Object.keys(SECTOR_TYPES).join(', ')}`);
|
|
40
|
+
const sortKey = String(args.sort ?? 'change').toLowerCase();
|
|
41
|
+
const sort = SORTS[sortKey];
|
|
42
|
+
if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
|
|
43
|
+
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
|
|
44
|
+
|
|
45
|
+
const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
|
|
46
|
+
url.searchParams.set('pn', '1');
|
|
47
|
+
url.searchParams.set('pz', String(limit));
|
|
48
|
+
url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
|
|
49
|
+
url.searchParams.set('np', '1');
|
|
50
|
+
url.searchParams.set('fltt', '2');
|
|
51
|
+
url.searchParams.set('invt', '2');
|
|
52
|
+
url.searchParams.set('fid', sort.fid);
|
|
53
|
+
url.searchParams.set('fs', fs);
|
|
54
|
+
url.searchParams.set('fields', 'f12,f14,f2,f3,f62,f104,f105,f128,f136,f140,f141');
|
|
55
|
+
url.searchParams.set('ut', 'b2884a393a59ad64002292a3e90d46a5');
|
|
56
|
+
|
|
57
|
+
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
|
58
|
+
if (!resp.ok) throw new CliError('HTTP_ERROR', `sectors failed: HTTP ${resp.status}`);
|
|
59
|
+
const data = await resp.json();
|
|
60
|
+
const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
|
|
61
|
+
if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no sector data');
|
|
62
|
+
|
|
63
|
+
return diff.slice(0, limit).map((it, i) => ({
|
|
64
|
+
rank: i + 1,
|
|
65
|
+
code: it.f12,
|
|
66
|
+
name: it.f14,
|
|
67
|
+
price: it.f2,
|
|
68
|
+
changePercent: it.f3,
|
|
69
|
+
mainNet: it.f62,
|
|
70
|
+
leadStock: it.f128,
|
|
71
|
+
leadChangePercent: it.f136,
|
|
72
|
+
upCount: it.f104,
|
|
73
|
+
downCount: it.f105,
|
|
74
|
+
}));
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'google-scholar',
|
|
6
|
+
name: 'search',
|
|
7
|
+
description: 'Google Scholar 学术搜索',
|
|
8
|
+
domain: 'scholar.google.com',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'authors', 'source', 'year', 'cited', 'url'],
|
|
16
|
+
navigateBefore: false,
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
|
19
|
+
const query = requireNonEmptyQuery(kwargs.query);
|
|
20
|
+
await page.goto(`https://scholar.google.com/scholar?q=${encodeURIComponent(query)}&hl=zh-CN`);
|
|
21
|
+
await page.wait(3);
|
|
22
|
+
const data = await page.evaluate(`
|
|
23
|
+
(() => {
|
|
24
|
+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
|
|
25
|
+
const results = [];
|
|
26
|
+
for (const el of document.querySelectorAll('.gs_r.gs_or.gs_scl, .gs_ri')) {
|
|
27
|
+
const container = el.querySelector('.gs_ri') || el;
|
|
28
|
+
const titleEl = container.querySelector('.gs_rt a, h3 a');
|
|
29
|
+
const title = normalize(titleEl?.textContent);
|
|
30
|
+
if (!title) continue;
|
|
31
|
+
|
|
32
|
+
const url = titleEl?.getAttribute('href') || '';
|
|
33
|
+
const infoLine = normalize(container.querySelector('.gs_a')?.textContent);
|
|
34
|
+
const parts = infoLine.split(' - ');
|
|
35
|
+
const authors = (parts[0] || '').trim();
|
|
36
|
+
const sourceParts = (parts[1] || '').split(',');
|
|
37
|
+
const source = sourceParts.slice(0, -1).join(',').trim() || sourceParts[0]?.trim() || '';
|
|
38
|
+
const year = infoLine.match(/(19|20)\\d{2}/)?.[0] || '';
|
|
39
|
+
const citedText = normalize(container.querySelector('.gs_fl a[href*="cites"]')?.textContent);
|
|
40
|
+
const cited = citedText.match(/(\\d+)/)?.[1] || '0';
|
|
41
|
+
|
|
42
|
+
results.push({
|
|
43
|
+
rank: results.length + 1,
|
|
44
|
+
title,
|
|
45
|
+
authors: authors.slice(0, 80),
|
|
46
|
+
source: source.slice(0, 60),
|
|
47
|
+
year,
|
|
48
|
+
cited,
|
|
49
|
+
url,
|
|
50
|
+
});
|
|
51
|
+
if (results.length >= ${limit}) break;
|
|
52
|
+
}
|
|
53
|
+
return results;
|
|
54
|
+
})()
|
|
55
|
+
`);
|
|
56
|
+
return Array.isArray(data) ? data : [];
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './search.js';
|
|
4
|
+
|
|
5
|
+
describe('google-scholar search command', () => {
|
|
6
|
+
const command = getRegistry().get('google-scholar/search');
|
|
7
|
+
|
|
8
|
+
it('registers as a public browser command', () => {
|
|
9
|
+
expect(command).toBeDefined();
|
|
10
|
+
expect(command.site).toBe('google-scholar');
|
|
11
|
+
expect(command.strategy).toBe('public');
|
|
12
|
+
expect(command.browser).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects empty queries before browser navigation', async () => {
|
|
16
|
+
const page = { goto: vi.fn() };
|
|
17
|
+
await expect(command.func(page, { query: ' ' })).rejects.toMatchObject({
|
|
18
|
+
name: 'ArgumentError',
|
|
19
|
+
code: 'ARGUMENT',
|
|
20
|
+
});
|
|
21
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './search.js';
|
|
4
|
+
import './recent.js';
|
|
5
|
+
|
|
6
|
+
describe('gov-law commands', () => {
|
|
7
|
+
const search = getRegistry().get('gov-law/search');
|
|
8
|
+
const recent = getRegistry().get('gov-law/recent');
|
|
9
|
+
|
|
10
|
+
it('registers both commands as public browser commands', () => {
|
|
11
|
+
expect(search).toBeDefined();
|
|
12
|
+
expect(recent).toBeDefined();
|
|
13
|
+
expect(search.strategy).toBe('public');
|
|
14
|
+
expect(recent.strategy).toBe('public');
|
|
15
|
+
expect(search.browser).toBe(true);
|
|
16
|
+
expect(recent.browser).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects empty search queries before browser navigation', async () => {
|
|
20
|
+
const page = { goto: vi.fn() };
|
|
21
|
+
await expect(search.func(page, { query: ' ' })).rejects.toMatchObject({
|
|
22
|
+
name: 'ArgumentError',
|
|
23
|
+
code: 'ARGUMENT',
|
|
24
|
+
});
|
|
25
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws a descriptive error when Vue Router is unavailable', async () => {
|
|
29
|
+
const page = {
|
|
30
|
+
goto: vi.fn(),
|
|
31
|
+
wait: vi.fn(),
|
|
32
|
+
evaluate: vi.fn().mockResolvedValue(false),
|
|
33
|
+
};
|
|
34
|
+
await expect(recent.func(page, { limit: 3 })).rejects.toMatchObject({
|
|
35
|
+
name: 'CliError',
|
|
36
|
+
code: 'FRAMEWORK_CHANGED',
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { clampInt } from '../_shared/common.js';
|
|
3
|
+
import { extractLawResults, navigateViaVueRouter } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'gov-law',
|
|
7
|
+
name: 'recent',
|
|
8
|
+
description: '最新法律法规',
|
|
9
|
+
domain: 'flk.npc.gov.cn',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: true,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'status', 'publish_date', 'type', 'department'],
|
|
16
|
+
navigateBefore: false,
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
|
19
|
+
await navigateViaVueRouter(page, {});
|
|
20
|
+
return extractLawResults(page, limit);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
|
|
3
|
+
import { extractLawResults, navigateViaVueRouter } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'gov-law',
|
|
7
|
+
name: 'search',
|
|
8
|
+
description: '国家法律法规数据库搜索',
|
|
9
|
+
domain: 'flk.npc.gov.cn',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: true,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
|
|
14
|
+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['rank', 'title', 'status', 'publish_date', 'type', 'department'],
|
|
17
|
+
navigateBefore: false,
|
|
18
|
+
func: async (page, kwargs) => {
|
|
19
|
+
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
|
20
|
+
const query = requireNonEmptyQuery(kwargs.query);
|
|
21
|
+
await navigateViaVueRouter(page, { searchWord: query });
|
|
22
|
+
|
|
23
|
+
const encodedQuery = JSON.stringify(query);
|
|
24
|
+
await page.evaluate(`
|
|
25
|
+
(async () => {
|
|
26
|
+
const input = document.querySelector('.el-input__inner');
|
|
27
|
+
if (input && !input.value) {
|
|
28
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
|
29
|
+
setter.call(input, ${encodedQuery});
|
|
30
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
31
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
32
|
+
await new Promise(r => setTimeout(r, 300));
|
|
33
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }));
|
|
34
|
+
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true }));
|
|
35
|
+
}
|
|
36
|
+
})()
|
|
37
|
+
`);
|
|
38
|
+
await page.wait(3);
|
|
39
|
+
return extractLawResults(page, limit);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CliError } from '@jackwener/opencli/errors';
|
|
2
|
+
|
|
3
|
+
export async function navigateViaVueRouter(page, query) {
|
|
4
|
+
await page.goto('https://flk.npc.gov.cn/index.html');
|
|
5
|
+
await page.wait(4);
|
|
6
|
+
|
|
7
|
+
const routerAvailable = await page.evaluate(`
|
|
8
|
+
(async () => {
|
|
9
|
+
const app = document.querySelector('#app');
|
|
10
|
+
const router = app?.__vue_app__?.config?.globalProperties?.$router;
|
|
11
|
+
if (!router) return false;
|
|
12
|
+
await router.push({ path: '/search', query: ${JSON.stringify(query)} });
|
|
13
|
+
return true;
|
|
14
|
+
})()
|
|
15
|
+
`);
|
|
16
|
+
|
|
17
|
+
if (!routerAvailable) {
|
|
18
|
+
throw new CliError(
|
|
19
|
+
'FRAMEWORK_CHANGED',
|
|
20
|
+
'Could not access Vue Router on flk.npc.gov.cn — the site may have been restructured.',
|
|
21
|
+
'Please report this issue so the adapter can be updated.',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await page.wait(5);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function extractLawResults(page, limit) {
|
|
29
|
+
const data = await page.evaluate(`
|
|
30
|
+
(async () => {
|
|
31
|
+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
|
|
32
|
+
for (let i = 0; i < 40; i++) {
|
|
33
|
+
if (document.querySelectorAll('.result-item').length > 0) break;
|
|
34
|
+
await new Promise(r => setTimeout(r, 500));
|
|
35
|
+
}
|
|
36
|
+
const results = [];
|
|
37
|
+
for (const el of document.querySelectorAll('.result-item')) {
|
|
38
|
+
const title = normalize(el.querySelector('.title-content')?.textContent);
|
|
39
|
+
if (!title) continue;
|
|
40
|
+
const status = normalize(el.querySelector('[class*="status"]')?.textContent);
|
|
41
|
+
const publish_date = normalize(el.querySelector('.publish-time')?.textContent).replace(/^公布日期[::]\\s*/, '');
|
|
42
|
+
const type = normalize(el.querySelector('.type')?.textContent);
|
|
43
|
+
const department = normalize(el.querySelector('.department')?.textContent);
|
|
44
|
+
results.push({ rank: results.length + 1, title, status, publish_date, type, department });
|
|
45
|
+
if (results.length >= ${limit}) break;
|
|
46
|
+
}
|
|
47
|
+
return results;
|
|
48
|
+
})()
|
|
49
|
+
`);
|
|
50
|
+
return Array.isArray(data) ? data : [];
|
|
51
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './search.js';
|
|
4
|
+
import './recent.js';
|
|
5
|
+
|
|
6
|
+
describe('gov-policy commands', () => {
|
|
7
|
+
const search = getRegistry().get('gov-policy/search');
|
|
8
|
+
const recent = getRegistry().get('gov-policy/recent');
|
|
9
|
+
|
|
10
|
+
it('registers both commands as public browser commands', () => {
|
|
11
|
+
expect(search).toBeDefined();
|
|
12
|
+
expect(recent).toBeDefined();
|
|
13
|
+
expect(search.browser).toBe(true);
|
|
14
|
+
expect(recent.browser).toBe(true);
|
|
15
|
+
expect(search.strategy).toBe('public');
|
|
16
|
+
expect(recent.strategy).toBe('public');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects empty search queries before browser navigation', async () => {
|
|
20
|
+
const page = { goto: vi.fn() };
|
|
21
|
+
await expect(search.func(page, { query: ' ' })).rejects.toMatchObject({
|
|
22
|
+
name: 'ArgumentError',
|
|
23
|
+
code: 'ARGUMENT',
|
|
24
|
+
});
|
|
25
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { clampInt } from '../_shared/common.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'gov-policy',
|
|
6
|
+
name: 'recent',
|
|
7
|
+
description: '国务院最新政策文件',
|
|
8
|
+
domain: 'www.gov.cn',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['rank', 'title', 'date', 'source', 'url'],
|
|
15
|
+
navigateBefore: false,
|
|
16
|
+
func: async (page, kwargs) => {
|
|
17
|
+
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
|
18
|
+
await page.goto('https://www.gov.cn/zhengce/zuixin/index.htm');
|
|
19
|
+
await page.wait(4);
|
|
20
|
+
const data = await page.evaluate(`
|
|
21
|
+
(async () => {
|
|
22
|
+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
|
|
23
|
+
for (let i = 0; i < 20; i++) {
|
|
24
|
+
if (document.querySelector('.news_box li, .list li, .list_item, .news-list li')) break;
|
|
25
|
+
await new Promise(r => setTimeout(r, 500));
|
|
26
|
+
}
|
|
27
|
+
const results = [];
|
|
28
|
+
for (const el of document.querySelectorAll('.news_box li, .list li, .list_item, .news-list li')) {
|
|
29
|
+
const titleEl = el.querySelector('a');
|
|
30
|
+
const title = normalize(titleEl?.textContent);
|
|
31
|
+
if (!title || title.length < 4) continue;
|
|
32
|
+
|
|
33
|
+
let url = titleEl?.getAttribute('href') || '';
|
|
34
|
+
if (url && !url.startsWith('http')) url = 'https://www.gov.cn' + url;
|
|
35
|
+
|
|
36
|
+
const date = (el.textContent || '').match(/(\\d{4}[-./]\\d{1,2}[-./]\\d{1,2})/)?.[1] || '';
|
|
37
|
+
const source = normalize(el.querySelector('.source, .from')?.textContent);
|
|
38
|
+
|
|
39
|
+
results.push({ rank: results.length + 1, title, date, source, url });
|
|
40
|
+
if (results.length >= ${limit}) break;
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
})()
|
|
44
|
+
`);
|
|
45
|
+
return Array.isArray(data) ? data : [];
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'gov-policy',
|
|
6
|
+
name: 'search',
|
|
7
|
+
description: '中国政府网政策文件搜索',
|
|
8
|
+
domain: 'sousuo.www.gov.cn',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'description', 'date', 'url'],
|
|
16
|
+
navigateBefore: false,
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
|
19
|
+
const query = requireNonEmptyQuery(kwargs.query);
|
|
20
|
+
await page.goto(`https://sousuo.www.gov.cn/sousuo/search.shtml?code=17da70961a7&dataTypeId=107&searchWord=${encodeURIComponent(query)}`);
|
|
21
|
+
await page.wait(5);
|
|
22
|
+
const data = await page.evaluate(`
|
|
23
|
+
(async () => {
|
|
24
|
+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
|
|
25
|
+
for (let i = 0; i < 30; i++) {
|
|
26
|
+
if (document.querySelectorAll('.basic_result_content .item, .js_basic_result_content .item').length > 0) break;
|
|
27
|
+
await new Promise(r => setTimeout(r, 500));
|
|
28
|
+
}
|
|
29
|
+
const results = [];
|
|
30
|
+
for (const el of document.querySelectorAll('.basic_result_content .item, .js_basic_result_content .item')) {
|
|
31
|
+
const titleEl = el.querySelector('a.title, .title a, a.log-anchor');
|
|
32
|
+
const title = normalize(titleEl?.textContent).replace(/<[^>]+>/g, '');
|
|
33
|
+
if (!title || title.length < 4) continue;
|
|
34
|
+
|
|
35
|
+
let url = titleEl?.getAttribute('href') || '';
|
|
36
|
+
if (url && !url.startsWith('http')) url = 'https://www.gov.cn' + url;
|
|
37
|
+
|
|
38
|
+
const description = normalize(el.querySelector('.description')?.textContent).slice(0, 120);
|
|
39
|
+
const date = (el.textContent || '').match(/(\\d{4}[-./]\\d{1,2}[-./]\\d{1,2})/)?.[1] || '';
|
|
40
|
+
results.push({ rank: results.length + 1, title, description, date, url });
|
|
41
|
+
if (results.length >= ${limit}) break;
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
})()
|
|
45
|
+
`);
|
|
46
|
+
return Array.isArray(data) ? data : [];
|
|
47
|
+
},
|
|
48
|
+
});
|
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
|
});
|