candidate-pool 3.0.1 → 3.0.2

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/eod/lib/http.js CHANGED
@@ -22,6 +22,10 @@
22
22
 
23
23
  const https = require('https');
24
24
 
25
+ // keep-alive 连接复用:省掉每次请求的 TCP/TLS 握手(全流程 ~500-700 次请求)。
26
+ // 经验证(Node 22):空闲套接字不阻塞进程退出,脚本可正常结束。
27
+ const AGENT = new https.Agent({ keepAlive: true, maxSockets: 8, maxFreeSockets: 8, keepAliveMsecs: 30000 });
28
+
25
29
  // 用完整浏览器 UA:残缺 UA(如裸 "Mozilla/5.0")本身就是风控指纹特征。
26
30
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
27
31
 
@@ -60,68 +64,67 @@ function looksLikeWaf(txt) {
60
64
  * - isWaf 自定义「这条响应算不算被拦截」判定(默认 looksLikeWaf)
61
65
  * @returns {Promise<any>} 文本或已解析的 JSON
62
66
  */
63
- function httpGet(url, opts = {}) {
67
+ async function httpGet(url, opts = {}) {
64
68
  const {
65
69
  enc = 'utf8', json = false, retries = 4, timeout = 15000,
66
70
  referer = 'https://gu.qq.com/', isWaf = null,
67
71
  } = opts;
68
72
 
69
- return new Promise(async (resolve, reject) => {
70
- let lastErr = null;
71
- let wafHits = 0;
72
-
73
- for (let i = 0; i < retries; i++) {
74
- if (i > 0) {
75
- // 被拦截 -> 长退避;普通网络抖动 -> 短退避
76
- const wait = (lastErr && lastErr.waf)
77
- ? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
78
- : 500 * i;
79
- await sleep(wait);
80
- }
81
- try {
82
- const { status, txt } = await new Promise((res, rej) => {
83
- const req = https.get(url, {
84
- timeout,
85
- headers: {
86
- 'User-Agent': UA,
87
- Referer: referer,
88
- Accept: '*/*',
89
- 'Accept-Language': 'zh-CN,zh;q=0.9',
90
- },
91
- }, (resp) => {
92
- const chunks = [];
93
- resp.on('data', (c) => chunks.push(c));
94
- resp.on('end', () => res({ status: resp.statusCode, txt: decode(Buffer.concat(chunks), enc) }));
95
- });
96
- req.on('error', rej);
97
- req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
73
+ let lastErr = null;
74
+ let wafHits = 0;
75
+
76
+ for (let i = 0; i < retries; i++) {
77
+ if (i > 0) {
78
+ // 被拦截 -> 长退避;普通网络抖动 -> 短退避
79
+ const wait = (lastErr && lastErr.waf)
80
+ ? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
81
+ : 500 * i;
82
+ await sleep(wait);
83
+ }
84
+ try {
85
+ const { status, txt } = await new Promise((res, rej) => {
86
+ const req = https.get(url, {
87
+ timeout,
88
+ agent: AGENT,
89
+ headers: {
90
+ 'User-Agent': UA,
91
+ Referer: referer,
92
+ Accept: '*/*',
93
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
94
+ },
95
+ }, (resp) => {
96
+ const chunks = [];
97
+ resp.on('data', (c) => chunks.push(c));
98
+ resp.on('end', () => res({ status: resp.statusCode, txt: decode(Buffer.concat(chunks), enc) }));
98
99
  });
100
+ req.on('error', rej);
101
+ req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
102
+ });
99
103
 
100
- // WAF 拦截:状态码判定 + 正文嗅探,两者都查
101
- const waf = (isWaf ? isWaf(txt, status) : false) || status === 501 || looksLikeWaf(txt);
102
- if (waf) {
103
- wafHits++;
104
- const e = new Error(`被接口反爬拦截(HTTP ${status})`);
105
- e.waf = true;
106
- throw e;
107
- }
108
- if (status && status >= 400) throw new Error(`HTTP ${status}`);
109
-
110
- if (!json) return resolve(txt.trim());
111
- const body = txt.trim();
112
- try { return resolve(JSON.parse(body)); }
113
- catch (e) {
114
- // 兼容 JSONP 包裹:name({...});
115
- const m = body.match(/^[a-zA-Z0-9_$.]+\(([\s\S]*)\);?$/);
116
- if (m) { try { return resolve(JSON.parse(m[1])); } catch (e2) { /* 落到下面报错 */ } }
117
- throw new Error('JSON 解析失败: ' + body.slice(0, 120));
118
- }
119
- } catch (e) {
120
- lastErr = e;
104
+ // WAF 拦截:状态码判定 + 正文嗅探,两者都查
105
+ const waf = (isWaf ? isWaf(txt, status) : false) || status === 501 || looksLikeWaf(txt);
106
+ if (waf) {
107
+ wafHits++;
108
+ const e = new Error(`被接口反爬拦截(HTTP ${status})`);
109
+ e.waf = true;
110
+ throw e;
111
+ }
112
+ if (status && status >= 400) throw new Error(`HTTP ${status}`);
113
+
114
+ if (!json) return txt.trim();
115
+ const body = txt.trim();
116
+ try { return JSON.parse(body); }
117
+ catch (e) {
118
+ // 兼容 JSONP 包裹:name({...});
119
+ const m = body.match(/^[a-zA-Z0-9_$.]+\(([\s\S]*)\);?$/);
120
+ if (m) { try { return JSON.parse(m[1]); } catch (e2) { /* 落到下面报错 */ } }
121
+ throw new Error('JSON 解析失败: ' + body.slice(0, 120));
121
122
  }
123
+ } catch (e) {
124
+ lastErr = e;
122
125
  }
123
- reject(lastErr || new Error('httpGet 失败'));
124
- });
126
+ }
127
+ throw lastErr || new Error('httpGet 失败');
125
128
  }
126
129
 
127
130
  /**
package/gen_candidates.js CHANGED
@@ -42,6 +42,9 @@
42
42
  const fs = require('fs');
43
43
  const path = require('path');
44
44
  const https = require('https');
45
+ // keep-alive 连接复用(与 eod/lib/http.js 同口径):观察池逐只抓取 ~360×3 类请求,
46
+ // 复用 TCP/TLS 省掉大半握手耗时。空闲套接字经验证不阻塞进程退出(Node 22)。
47
+ const AGENT = new https.Agent({ keepAlive: true, maxSockets: 8, maxFreeSockets: 8, keepAliveMsecs: 30000 });
45
48
  const { now: nowStr, dayjs } = require('./time');
46
49
  // 工作目录解析:产物/配置的落点走 paths,代码资产(本文件等)仍锚在包内。
47
50
  // 默认 HOME = 包根(与历史 __dirname 行为一致);可用 CANDIDATE_POOL_HOME / --cwd 重定位。
@@ -121,44 +124,42 @@ function looksLikeWaf(txt) {
121
124
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
122
125
  const WAF_BACKOFF = [2000, 5000, 12000, 25000]; // 命中拦截时的退避梯度(ms)
123
126
 
124
- function httpGet(url, { json = true, retries = 4, timeout = 15000 } = {}) {
125
- return new Promise(async (resolve, reject) => {
126
- let lastErr;
127
- let wafHits = 0;
128
- for (let i = 0; i < retries; i++) {
129
- if (i > 0) {
130
- // 拦截用长退避,普通网络错误用短退避
131
- await sleep(lastErr && lastErr.waf
132
- ? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
133
- : 500 * i);
134
- }
135
- try {
136
- const { status, txt } = await new Promise((res, rej) => {
137
- const req = https.get(url, { timeout, headers: { 'User-Agent': UA, 'Referer': 'https://gu.qq.com/', 'Accept': '*/*' } }, (resp) => {
138
- let d = ''; resp.on('data', c => d += c); resp.on('end', () => res({ status: resp.statusCode, txt: d.trim() }));
139
- });
140
- req.on('error', rej);
141
- req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
142
- });
143
- // WAF 拦截返回 HTTP 501 + 跳转页;状态码判定比嗅探 body 更可靠,两者都查。
144
- if (status === 501 || looksLikeWaf(txt)) {
145
- wafHits++;
146
- const e = new Error(`被接口反爬拦截(HTTP ${status})`);
147
- e.waf = true;
148
- throw e;
149
- }
150
- if (status && status >= 400) throw new Error(`HTTP ${status}`);
151
- if (!json) return resolve(txt);
152
- try { return resolve(JSON.parse(txt)); }
153
- catch (e) {
154
- const m = txt.match(/^[a-zA-Z0-9_]+\(([\s\S]*)\);?\s*$/);
155
- if (m) { try { return resolve(JSON.parse(m[1])); } catch (e2) {} }
156
- throw new Error('JSON 解析失败: ' + txt.slice(0, 120));
157
- }
158
- } catch (e) { lastErr = e; }
127
+ async function httpGet(url, { json = true, retries = 4, timeout = 15000 } = {}) {
128
+ let lastErr;
129
+ let wafHits = 0;
130
+ for (let i = 0; i < retries; i++) {
131
+ if (i > 0) {
132
+ // 拦截用长退避,普通网络错误用短退避
133
+ await sleep(lastErr && lastErr.waf
134
+ ? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
135
+ : 500 * i);
159
136
  }
160
- reject(lastErr || new Error('httpGet 失败'));
161
- });
137
+ try {
138
+ const { status, txt } = await new Promise((res, rej) => {
139
+ const req = https.get(url, { timeout, agent: AGENT, headers: { 'User-Agent': UA, 'Referer': 'https://gu.qq.com/', 'Accept': '*/*' } }, (resp) => {
140
+ let d = ''; resp.on('data', c => d += c); resp.on('end', () => res({ status: resp.statusCode, txt: d.trim() }));
141
+ });
142
+ req.on('error', rej);
143
+ req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
144
+ });
145
+ // WAF 拦截返回 HTTP 501 + 跳转页;状态码判定比嗅探 body 更可靠,两者都查。
146
+ if (status === 501 || looksLikeWaf(txt)) {
147
+ wafHits++;
148
+ const e = new Error(`被接口反爬拦截(HTTP ${status})`);
149
+ e.waf = true;
150
+ throw e;
151
+ }
152
+ if (status && status >= 400) throw new Error(`HTTP ${status}`);
153
+ if (!json) return txt;
154
+ try { return JSON.parse(txt); }
155
+ catch (e) {
156
+ const m = txt.match(/^[a-zA-Z0-9_]+\(([\s\S]*)\);?\s*$/);
157
+ if (m) { try { return JSON.parse(m[1]); } catch (e2) {} }
158
+ throw new Error('JSON 解析失败: ' + txt.slice(0, 120));
159
+ }
160
+ } catch (e) { lastErr = e; }
161
+ }
162
+ throw lastErr || new Error('httpGet 失败');
162
163
  }
163
164
 
164
165
  // 候选源:HOME 下 candidates.json 配置的可编辑观察池(替代 westock ranking CompScore)
@@ -487,7 +488,7 @@ function buildHTML(m) {
487
488
 
488
489
  // 核心结论
489
490
  const best = [...universe].sort((a, b) => (b.r01.core || 0) - (a.r01.core || 0))[0];
490
- const bestStr = best && best.r01.core ? `${esc(best.name)}(${best.code.replace(/^[sh|sz]/, '')}),四项中达成 ${best.r01.core} 项${best.r01.capOk ? ',市值达标' : ''}` : '无';
491
+ const bestStr = best && best.r01.core ? `${esc(best.name)}(${best.code.replace(/^(sh|sz|bj)/, '')}),四项中达成 ${best.r01.core} 项${best.r01.capOk ? ',市值达标' : ''}` : '无';
491
492
  const conclusion = [
492
493
  `<b>${b('R01 ' + (r01Triggers ? `硬触发 ${r01Triggers} 只` : '零硬触发'), 'R01 ' + (r01Triggers ? `${r01Triggers} hard trigger(s)` : 'zero hard triggers'))}。</b>` +
493
494
  (r01Triggers ? '' : `${universe.length} 只标的无一满足全部门槛。最接近者为 ${bestStr}。`),
@@ -522,7 +523,7 @@ function buildHTML(m) {
522
523
  const tier = tierOf.get(s.code) || 'excluded';
523
524
  const cls = r.ok ? ' class="hit"' : '';
524
525
  matrixRows += `<tr data-tier="${tier}"${cls}>` +
525
- `<td>${s.code.replace(/^[sh|sz]/, '')}</td>` +
526
+ `<td>${s.code.replace(/^(sh|sz|bj)/, '')}</td>` +
526
527
  `<td>${b(s.name, s.name)}</td>` +
527
528
  `<td><span class="tag tier-${tier}">${TIER_LABEL[tier]}</span></td>` +
528
529
  `<td>${num(r.close)}</td>` +
@@ -545,7 +546,7 @@ function buildHTML(m) {
545
546
  let exRows = '';
546
547
  for (const s of excluded) {
547
548
  exRows += `<tr>` +
548
- `<td>${s.code.replace(/^[sh|sz]/, '')}</td>` +
549
+ `<td>${s.code.replace(/^(sh|sz|bj)/, '')}</td>` +
549
550
  `<td>${b(s.name, s.name)}</td>` +
550
551
  `<td class="wrap">${(s.excludeReasons || ['—']).map(r => b(r, r)).join(';')}</td>` +
551
552
  `</tr>`;
@@ -561,7 +562,7 @@ function buildHTML(m) {
561
562
  const r07 = s.r07 || {};
562
563
  return `<div class="card ${label === 'priority' ? 'priority' : ''}">` +
563
564
  `<div class="grid-2">` +
564
- `<div><div class="kv"><span class="label">${b('名称', 'Name')}:</span><span class="value">${b(s.name, s.name)}(${s.code.replace(/^[sh|sz]/, '')})</span></div>` +
565
+ `<div><div class="kv"><span class="label">${b('名称', 'Name')}:</span><span class="value">${b(s.name, s.name)}(${s.code.replace(/^(sh|sz|bj)/, '')})</span></div>` +
565
566
  `<div class="kv"><span class="label">${b('R01 达成', 'R01 score')}:</span><span class="value">${r.core}/4${r.capOk ? ',市值达标' : ',市值越线'}</span></div>` +
566
567
  `<div class="kv"><span class="label">${b('行业', 'Sector')}:</span><span class="value">${r07.sectorName || '—'}(${pct(r07.sectorPct)},排名 ${r07.sectorRank || '—'}/${r07.total || '—'})</span></div></div>` +
567
568
  `<div>${fund}` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candidate-pool",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
5
5
  "bin": {
6
6
  "candidate-pool": "bin/candidate-pool.js",
@@ -85,16 +85,16 @@
85
85
  "prepare": "husky"
86
86
  },
87
87
  "devDependencies": {
88
- "@commitlint/cli": "^21.2.3",
89
- "@commitlint/config-conventional": "^21.2.3",
88
+ "@commitlint/cli": "^21.2.4",
89
+ "@commitlint/config-conventional": "^21.2.4",
90
90
  "changelogen": "^0.6.2",
91
91
  "husky": "^9.1.7"
92
92
  },
93
93
  "engines": {
94
- "node": ">=18"
94
+ "node": ">=22.5"
95
95
  },
96
96
  "dependencies": {
97
97
  "dayjs": "^1.11.23",
98
- "dotenv": "^18.0.1"
98
+ "dotenv": "^18.0.8"
99
99
  }
100
100
  }