candidate-pool 2.1.0 → 2.1.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  所有变动按 **Conventional Commits** 约定分类,由 `changelogen` 自动生成。
4
4
 
5
+ ## v2.1.1
6
+
7
+ [compare changes](https://github.com/GuoSirius/candidate-pool/compare/v2.1.0...v2.1.1)
8
+
9
+ ### 🐛 缺陷修复 (Bug Fixes)
10
+
11
+ - **eod:** 行业映射新增新浪兜底源与防降级闸,修复 runner 上东财被封月更失败 ([244e879](https://github.com/GuoSirius/candidate-pool/commit/244e879))
12
+
13
+ ### 📦 杂项维护 (Chores)
14
+
15
+ - 每日实时初筛 2026-09-22 ([7944ff2](https://github.com/GuoSirius/candidate-pool/commit/7944ff2))
16
+ - 尾盘观察 2026-09-23 ([901dafc](https://github.com/GuoSirius/candidate-pool/commit/901dafc))
17
+ - 尾盘选股 2026-09-23 ([ad60b3a](https://github.com/GuoSirius/candidate-pool/commit/ad60b3a))
18
+ - 每日实时初筛 2026-09-23 ([f48a456](https://github.com/GuoSirius/candidate-pool/commit/f48a456))
19
+ - 尾盘观察 2026-09-24 ([1f1d604](https://github.com/GuoSirius/candidate-pool/commit/1f1d604))
20
+ - 尾盘选股 2026-09-24 ([e1a4bee](https://github.com/GuoSirius/candidate-pool/commit/e1a4bee))
21
+ - 每日实时初筛 2026-09-24 ([3d01e09](https://github.com/GuoSirius/candidate-pool/commit/3d01e09))
22
+ - 尾盘观察 2026-09-28 ([bc2ad4e](https://github.com/GuoSirius/candidate-pool/commit/bc2ad4e))
23
+ - 尾盘选股 2026-09-28 ([c00a7a1](https://github.com/GuoSirius/candidate-pool/commit/c00a7a1))
24
+ - 每日实时初筛 2026-09-28 ([08a4291](https://github.com/GuoSirius/candidate-pool/commit/08a4291))
25
+ - 尾盘观察 2026-09-29 ([91a952e](https://github.com/GuoSirius/candidate-pool/commit/91a952e))
26
+ - 尾盘选股 2026-09-29 ([380e3da](https://github.com/GuoSirius/candidate-pool/commit/380e3da))
27
+ - 每日实时初筛 2026-09-29 ([edbfc55](https://github.com/GuoSirius/candidate-pool/commit/edbfc55))
28
+ - 尾盘观察 2026-09-30 ([60cd5a3](https://github.com/GuoSirius/candidate-pool/commit/60cd5a3))
29
+ - 尾盘选股 2026-09-30 ([02f5fc0](https://github.com/GuoSirius/candidate-pool/commit/02f5fc0))
30
+ - 每日实时初筛 2026-09-30 ([05f384f](https://github.com/GuoSirius/candidate-pool/commit/05f384f))
31
+ - 尾盘观察 2026-10-08 ([9a41a50](https://github.com/GuoSirius/candidate-pool/commit/9a41a50))
32
+ - 尾盘选股 2026-10-08 ([9618588](https://github.com/GuoSirius/candidate-pool/commit/9618588))
33
+ - 每日实时初筛 2026-10-08 ([72fdc6e](https://github.com/GuoSirius/candidate-pool/commit/72fdc6e))
34
+
35
+ ### ❤️ Contributors
36
+
37
+ - 郭之存 ([@siriusSupreme](https://github.com/siriusSupreme))
38
+
5
39
  ## v2.1.0
6
40
 
7
41
  [compare changes](https://github.com/GuoSirius/candidate-pool/compare/v2.0.0...v2.1.0)
@@ -4,18 +4,31 @@
4
4
  * eod/build_industry_map.js —— 生成「代码 → 行业(+ 上市日期)」缓存
5
5
  * ---------------------------------------------------------------------------
6
6
  * 为什么单独做成低频脚本:
7
- * 行业归属几乎不变(一年也就调几次成分),但获取全市场映射必须走东财 clist
8
- * (一次 60 页、每页 100 只)。而**东财对突发高频请求会封 IP**(实测十余次请求后即
9
- * socket hang up),所以绝不能放进 14:50 的关键路径 —— 那里只读本脚本产出的缓存文件。
7
+ * 行业归属几乎不变(一年也就调几次成分),但获取全市场映射必须翻全市场列表,
8
+ * 而行情站对突发高频请求有风控(东财实测十余次请求即 socket hang up),所以
9
+ * 绝不能放进 14:50 的关键路径 —— 那里只读本脚本产出的缓存文件。
10
+ *
11
+ * 为什么需要双源(2026-10-01 月更事故):
12
+ * GitHub Actions runner 的机房段 IP 被**东财封禁**——月更运行第 1 页即在
13
+ * 3 个主机 × 3 次重试内全灭(第 1 页获取失败),0 只解析触发安全闸退出。
14
+ * 而腾讯行情(daily/tail 两个工作流的数据源)在 runner 上一直正常,说明
15
+ * 不是 runner 整体断网,而是东财单方面的机房 IP 风控。故增加一个在
16
+ * runner 上可达的兜底源。
10
17
  *
11
18
  * 源与降级:
12
19
  * 1. 东财 clist(主源,含 f100 行业 + f26 上市日期)
13
20
  * fs 覆盖 沪深A股 + 科创板 + 创业板 + 北交所;每页之间加节流,避免被封。
14
- * 2. 失败时**保留既有缓存文件**并以非零码退出,绝不用半截数据覆盖好数据。
21
+ * 2. 新浪行业分类(兜底源,runner 可达):newSinaHy.php 取行业节点列表,
22
+ * Market_Center.getHQNodeData 逐节点翻成分股(symbol 自带 sh/sz/bj 前缀)。
23
+ * 局限:不含上市日期(下游新股判定退回名称 N/C 前缀);北交所多数不在
24
+ * 新浪行业节点内,会落入统一的「未分类」桶。
25
+ * 任一源解析 < 3000 只即判定抓取不完整;两源都失败则**保留既有缓存**,
26
+ * 绝不用半截数据覆盖好数据。
15
27
  *
16
28
  * 用法:
17
- * node eod/build_industry_map.js # 刷新缓存
18
- * node eod/build_industry_map.js --dry # 只统计不写文件
29
+ * node eod/build_industry_map.js # 主源 -> 失败自动切兜底源
30
+ * node eod/build_industry_map.js --source=sina # 强制只走新浪源
31
+ * node eod/build_industry_map.js --dry # 只统计不写文件
19
32
  * ---------------------------------------------------------------------------
20
33
  */
21
34
 
@@ -27,6 +40,9 @@ const paths = require('../paths');
27
40
 
28
41
  // 输出位置跟随工作目录(见 paths.js):默认 <仓库根>/eod/data/industry-map.json
29
42
  const OUT = paths.industryMapFile();
43
+ const GATE = 3000; // 解析条数低于此值判定抓取不完整
44
+
45
+ // --------------------------- 源 1:东财 clist ------------------------------
30
46
 
31
47
  // 沪深A股 + 科创板 + 创业板 + 北交所。与全市场快照的代码覆盖面对齐。
32
48
  const FS_ALL = 'm:1+t:2,m:1+t:23,m:0+t:6,m:0+t:80,m:0+t:81+s:2048';
@@ -61,8 +77,8 @@ async function fetchPage(host, pn) {
61
77
  return httpGet(url, { json: true, retries: 3, timeout: 20000, referer: 'https://quote.eastmoney.com/' });
62
78
  }
63
79
 
64
- (async () => {
65
- const dry = process.argv.includes('--dry');
80
+ /** @returns {{stocks: Object, label: string} | null} 不完整时返回 null(不抛出) */
81
+ async function fetchFromEastmoney() {
66
82
  let host = null;
67
83
  const stocks = {};
68
84
  let page = 1;
@@ -111,14 +127,109 @@ async function fetchPage(host, pn) {
111
127
  process.stdout.write('\n');
112
128
 
113
129
  const n = Object.keys(stocks).length;
114
- console.log(`\n共解析 ${n} 只(${(n / (total || 1) * 100).toFixed(1)}% 覆盖)`);
130
+ console.log(`\n[东财源] 共解析 ${n} 只(${(n / (total || 1) * 100).toFixed(1)}% 覆盖)`);
131
+ if (n < GATE) return null;
132
+ return { stocks, label: `eastmoney clist (${host})` };
133
+ }
134
+
135
+ // --------------------------- 源 2:新浪行业分类 ----------------------------
136
+
137
+ const SINA_BASE = 'https://vip.stock.finance.sina.com.cn';
138
+ const SINA_REFERER = 'https://finance.sina.com.cn/';
139
+ const SINA_THROTTLE_MS = 150;
140
+ const SINA_NUM = 100; // getHQNodeData 单页上限
141
+
142
+ /** 行业节点列表:GBK 编码的 var 声明,值为 "key,行业名,数量,..." */
143
+ async function fetchSinaNodes() {
144
+ const txt = await httpGet(`${SINA_BASE}/q/view/newSinaHy.php`,
145
+ { enc: 'gbk', retries: 3, timeout: 20000, referer: SINA_REFERER });
146
+ const m = txt.match(/\{[\s\S]*\}/);
147
+ if (!m) throw new Error('新浪行业节点列表解析失败');
148
+ const obj = JSON.parse(m[0]);
149
+ const nodes = Object.entries(obj)
150
+ .map(([key, v]) => {
151
+ const p = String(v).split(',');
152
+ return { key, name: (p[1] || '').trim(), count: parseInt(p[2], 10) || 0 };
153
+ })
154
+ .filter((x) => x.name && x.count > 0);
155
+ if (!nodes.length) throw new Error('新浪行业节点为空');
156
+ return nodes;
157
+ }
158
+
159
+ /** @returns {{stocks: Object, label: string} | null} 单节点失败跳过,整体不完整时返回 null */
160
+ async function fetchFromSina() {
161
+ console.log('\n-- 切换兜底源:新浪行业分类 --');
162
+ let nodes;
163
+ try {
164
+ nodes = await fetchSinaNodes();
165
+ } catch (e) {
166
+ console.error(` 行业节点列表获取失败(${e.message})`);
167
+ return null;
168
+ }
169
+ console.log(` 行业节点 ${nodes.length} 个`);
170
+ const stocks = {};
171
+ for (const node of nodes) {
172
+ const pages = Math.ceil(node.count / SINA_NUM) || 1;
173
+ for (let p = 1; p <= pages; p++) {
174
+ const url = `${SINA_BASE}/quotes_service/api/json_v2.php/Market_Center.getHQNodeData`
175
+ + `?page=${p}&num=${SINA_NUM}&node=${node.key}&sort=symbol&asc=1`;
176
+ let rows;
177
+ try {
178
+ rows = await httpGet(url, { json: true, retries: 2, timeout: 20000, referer: SINA_REFERER });
179
+ } catch (e) {
180
+ console.error(`\n 节点「${node.name}」第 ${p} 页失败(${e.message}),跳过该节点`);
181
+ break;
182
+ }
183
+ if (!Array.isArray(rows) || !rows.length) break;
184
+ for (const r of rows) {
185
+ const sym = String(r.symbol || '');
186
+ if (!/^(sh|sz|bj)\d{6}$/.test(sym)) continue;
187
+ stocks[sym] = { name: String(r.name || '').trim(), sector: node.name, listed: null };
188
+ }
189
+ process.stdout.write(`\r ${node.name}(累计 ${Object.keys(stocks).length}) `);
190
+ if (rows.length < SINA_NUM) break;
191
+ await sleep(SINA_THROTTLE_MS);
192
+ }
193
+ await sleep(SINA_THROTTLE_MS);
194
+ }
195
+ process.stdout.write('\n');
196
+
197
+ const n = Object.keys(stocks).length;
198
+ const sectors = new Set(Object.values(stocks).map((v) => v.sector)).size;
199
+ // 闸门按「该源自洽完整」判定:新浪行业分类宇宙本身只有 ~3000 只(分类偏旧),
200
+ // 不能套东财的 3000 阈值;以节点列表声明的总数量为预期,抓到 >= 90% 即算完整。
201
+ const expected = nodes.reduce((s, x) => s + x.count, 0);
202
+ const gate = Math.max(1000, Math.floor(expected * 0.9));
203
+ console.log(`\n[新浪源] 共解析 ${n} 只,${sectors} 个行业(预期 ${expected},闸门 ${gate})`);
204
+ if (n < gate) return null;
205
+ return { stocks, label: 'sina industry nodes (vip.stock.finance.sina.com.cn)' };
206
+ }
207
+
208
+ // ---------------------------------- 主流程 ---------------------------------
209
+
210
+ (async () => {
211
+ const dry = process.argv.includes('--dry');
212
+ const srcArg = process.argv.find((a) => a.startsWith('--source='));
213
+ const forced = srcArg ? srcArg.split('=')[1] : null; // 'eastmoney' | 'sina'
115
214
 
116
- if (n < 3000) {
117
- console.error(`\n✗ 解析条数过少(${n}),判定抓取不完整,**不覆盖既有缓存**。`);
118
- console.error(' 若东财封禁本机 IP,可稍后重试;期间站点会沿用旧缓存(不影响运行)。');
215
+ let result = null;
216
+ if (forced !== 'sina') {
217
+ result = await fetchFromEastmoney();
218
+ if (!result) console.error(' 东财源抓取不完整,尝试新浪兜底源…');
219
+ }
220
+ if (!result && forced !== 'eastmoney') {
221
+ result = await fetchFromSina();
222
+ }
223
+
224
+ if (!result) {
225
+ console.error(`\n✗ 两个源均解析不足 ${GATE} 只,判定抓取不完整,**不覆盖既有缓存**。`);
226
+ console.error(' 期间站点沿用旧缓存(不影响运行);可稍后重试或在本机手动执行。');
119
227
  process.exit(1);
120
228
  }
121
229
 
230
+ const { stocks, label } = result;
231
+ const n = Object.keys(stocks).length;
232
+
122
233
  // 行业分布概览
123
234
  const bySector = {};
124
235
  for (const v of Object.values(stocks)) bySector[v.sector] = (bySector[v.sector] || 0) + 1;
@@ -130,9 +241,18 @@ async function fetchPage(host, pn) {
130
241
 
131
242
  if (dry) { console.log('\n--dry:未写入文件'); return; }
132
243
 
244
+ // 防降级闸:部分抓取(如东财 74% 覆盖也 >= 3000 过闸)或兜底源(新浪仅 ~3000)
245
+ // 都不能覆盖既有的大映射 —— 缓存条数明显缩水视为降级,保留旧数据。
246
+ let prevCount = 0;
247
+ try { prevCount = JSON.parse(fs.readFileSync(OUT, 'utf8')).count || 0; } catch (e) { /* 首次运行无缓存 */ }
248
+ if (prevCount > n * 1.2) {
249
+ console.log(`\n既有缓存 ${prevCount} 条明显大于本次 ${n} 条,判定为覆盖面降级,**保留旧缓存不覆盖**。`);
250
+ return; // 缓存保留即视为成功,不触发工作流失败/提交
251
+ }
252
+
133
253
  const payload = {
134
254
  generatedAt: now(),
135
- source: `eastmoney clist (${host})`,
255
+ source: label,
136
256
  count: n,
137
257
  stocks,
138
258
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candidate-pool",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
5
5
  "bin": {
6
6
  "candidate-pool": "bin/candidate-pool.js",