candidate-pool 2.0.0

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/scaffold.js ADDED
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+ /*
3
+ * scaffold.js —— 首次运行脚手架(Phase 1)
4
+ * ---------------------------------------------------------------------------
5
+ * 解决什么问题:
6
+ * 「拉代码运行」时仓库里什么都有;但装成 npm 包(或 npx)后,工作目录是空的,
7
+ * 首跑会直接因为缺 candidates.json 报错,或者跑出来「全市场塌成一个未分类」。
8
+ * 本模块在入口处做一次幂等的补齐,让空目录也能跑起来。
9
+ *
10
+ * 三条不可动摇的规则(都是为了向后兼容):
11
+ * 1. **只创建缺失的目录,绝不覆盖任何已存在的文件**。
12
+ * 老仓库里已有的 candidates.json / notify_config.json / db/.env 一个字都不会动。
13
+ * 2. **不生成任何含凭据的配置文件**。
14
+ * notify_config.json / db/.env 一旦被写入占位值,程序会从「未配置 → 跳过」
15
+ * 变成「配了但值是假的 → 真的去发/去连」,行为静默改变。这两份**只提示不创建**。
16
+ * 3. 重复调用零副作用(幂等),失败也只降级为提示、不阻断主流程。
17
+ *
18
+ * 补什么(全部可从包内模板/资产取得):
19
+ * <HOME>/data、reports、eod/data、eod/reports、db 目录
20
+ * <HOME>/candidates.json ← 包内 candidates.example.json(硬阻塞项,缺它直接报错)
21
+ * eod/data/industry-map.json 不复制 —— eod/lib/sector.js 会直接回退读包内那份种子
22
+ *
23
+ * 不补什么(只打印提示,交给用户决定):
24
+ * notify_config.json ← notify_config.example.json(推送凭据)
25
+ * db/.env ← db/.env.example(Cloudflare D1 凭据)
26
+ * ---------------------------------------------------------------------------
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const paths = require('./paths');
32
+
33
+ /** 需要在工作目录里存在的目录(缺则建) */
34
+ function dirs() {
35
+ return [
36
+ paths.dataDir(),
37
+ paths.reportsDir(),
38
+ paths.eodDataDir(),
39
+ paths.eodReportsDir(),
40
+ path.dirname(paths.localDb()),
41
+ ];
42
+ }
43
+
44
+ /**
45
+ * 播种文件:目标 ← 包内模板。
46
+ * 只放「缺失即硬报错」的项;candidates.json 是唯一一个。
47
+ */
48
+ function seeds() {
49
+ return [
50
+ {
51
+ to: paths.candidatesFile(),
52
+ from: paths.codeAsset('candidates.example.json'),
53
+ label: '候选观察池',
54
+ required: true,
55
+ note: '来自示例模板(52 只,覆盖 26 个行业)。建议用 `node build_universe.js` 按自己的市值门槛重建。',
56
+ },
57
+ ];
58
+ }
59
+
60
+ /** 只提示、不创建的配置文件(含凭据,占位值会改变程序行为) */
61
+ function hints() {
62
+ return [
63
+ {
64
+ to: paths.notifyConfigFile(),
65
+ from: paths.codeAsset('notify_config.example.json'),
66
+ label: '微信 / 邮件推送配置',
67
+ when: '需要跑完自动推送时',
68
+ },
69
+ {
70
+ to: paths.dbEnvFile(),
71
+ from: paths.codeAsset('db', '.env.example'),
72
+ label: 'Cloudflare D1 凭据(写入线上库用)',
73
+ when: '需要把结果同步到网页/D1 时',
74
+ },
75
+ ];
76
+ }
77
+
78
+ /** 是否处于「被当作依赖安装」的目录里(node_modules / npx 缓存) */
79
+ function looksInstalled(dir = paths.home()) {
80
+ return /[\\/]node_modules[\\/]/.test(dir + path.sep);
81
+ }
82
+
83
+ /**
84
+ * 补齐工作目录。幂等、不覆盖、不抛错(内部错误降级为 warning)。
85
+ *
86
+ * @param {object} [o]
87
+ * @param {boolean} [o.force] 即使没有任何缺失也返回完整报告(`--init` 用)
88
+ * @returns {{home:string, source:string, relocated:boolean, installed:boolean,
89
+ * createdDirs:string[], createdFiles:Array, hints:Array, missing:Array}}
90
+ */
91
+ function ensureWorkspace({ force = false } = {}) {
92
+ const home = paths.home();
93
+ const createdDirs = [];
94
+ const createdFiles = [];
95
+ const missing = [];
96
+
97
+ for (const d of dirs()) {
98
+ if (fs.existsSync(d)) continue;
99
+ try {
100
+ fs.mkdirSync(d, { recursive: true });
101
+ createdDirs.push(d);
102
+ } catch (e) {
103
+ missing.push({ path: d, reason: String((e && e.message) || e) });
104
+ }
105
+ }
106
+
107
+ for (const s of seeds()) {
108
+ if (fs.existsSync(s.to)) continue;
109
+ if (!fs.existsSync(s.from)) {
110
+ // 包内模板都不在(例如被 --ignore-files 裁掉)→ 不阻断,交给上游报错
111
+ missing.push({ path: s.to, reason: `包内模板缺失:${s.from}` });
112
+ continue;
113
+ }
114
+ try {
115
+ fs.copyFileSync(s.from, s.to);
116
+ createdFiles.push(s);
117
+ } catch (e) {
118
+ missing.push({ path: s.to, reason: String((e && e.message) || e) });
119
+ }
120
+ }
121
+
122
+ return {
123
+ home,
124
+ source: paths.homeSource(),
125
+ relocated: paths.isRelocated(),
126
+ installed: looksInstalled(home),
127
+ createdDirs,
128
+ createdFiles,
129
+ hints: hints().filter((h) => !fs.existsSync(h.to)),
130
+ missing,
131
+ force,
132
+ };
133
+ }
134
+
135
+ /** 把报告渲染成若干行日志(没有任何变化时返回空数组,保持静默) */
136
+ function renderReport(r) {
137
+ const lines = [];
138
+ const changed = r.createdDirs.length + r.createdFiles.length;
139
+ if (!changed && !r.missing.length) return lines;
140
+
141
+ lines.push('── 首次运行脚手架 ──');
142
+ for (const d of r.createdDirs) lines.push(` 新建目录 ${d}`);
143
+ for (const f of r.createdFiles) {
144
+ lines.push(` 创建文件 ${f.to}`);
145
+ lines.push(` ↳ ${f.label}:${f.note}`);
146
+ }
147
+ for (const m of r.missing) lines.push(` ⚠ 未能创建 ${m.path}(${m.reason})`);
148
+
149
+ if (r.installed) {
150
+ lines.push('');
151
+ lines.push(' ⚠ 当前工作目录在 node_modules 里 —— 产物会随重装/npx 清缓存一起丢失。');
152
+ lines.push(' 建议显式指定工作目录,例如:');
153
+ lines.push(' npx candidate-pool --cwd ~/my-pool');
154
+ lines.push(' # 或 export CANDIDATE_POOL_HOME=~/my-pool');
155
+ }
156
+ return lines;
157
+ }
158
+
159
+ /** 提示可选配置(只在用户显式 `--init` 时打印,避免日常刷屏) */
160
+ function renderHints(r) {
161
+ if (!r.hints.length) return [];
162
+ const lines = ['', '── 可选配置(未自动创建,需要时手动复制)──'];
163
+ for (const h of r.hints) {
164
+ lines.push(` ${h.label}(${h.when}):`);
165
+ lines.push(` cp "${h.from}" "${h.to}"`);
166
+ }
167
+ return lines;
168
+ }
169
+
170
+ /**
171
+ * 入口处调用:补齐工作目录并按需打印。
172
+ * 日常运行只在实际创建了东西时才有输出(正常情况下完全静默)。
173
+ *
174
+ * @param {object} [o]
175
+ * @param {(s:string)=>void} [o.log] 日志函数(默认 console.log)
176
+ * @param {boolean} [o.init] 显式 `--init`:无条件打印报告 + 可选配置提示
177
+ */
178
+ function autoInit({ log = console.log, init = false } = {}) {
179
+ let r;
180
+ try {
181
+ r = ensureWorkspace({ force: init });
182
+ } catch (e) {
183
+ log(`⚠ 工作目录初始化失败(已忽略,继续运行):${(e && e.message) || e}`);
184
+ return null;
185
+ }
186
+ // `--init` 是「我想确认一下环境」的显式请求 → 无论有没有变化都要有回音;
187
+ // 日常运行则完全静默(只在实际创建了东西时出声)。
188
+ if (init) {
189
+ const report = renderReport(r);
190
+ if (report.length) for (const l of report) log(l);
191
+ else log(`── 工作目录已就绪(无需创建任何文件)── ${r.home} [${r.source}]`);
192
+ for (const l of renderHints(r)) log(l);
193
+ return r;
194
+ }
195
+ for (const l of renderReport(r)) log(l);
196
+ return r;
197
+ }
198
+
199
+ module.exports = { ensureWorkspace, autoInit, renderReport, renderHints, dirs, seeds, hints, looksInstalled };
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+ /*
3
+ * scaffold.selftest.js —— 首次运行脚手架的回归自测
4
+ * ---------------------------------------------------------------------------
5
+ * 为什么必须自测:脚手架是**唯一会往用户目录写文件**的自动流程。它一旦越界
6
+ * (覆盖已有配置、写出占位凭据),后果是「用户的数据没了」或「程序行为静默改变」,
7
+ * 而且只在首次运行时发生 —— 手工测一次根本触发不到第二次。
8
+ *
9
+ * 覆盖:创建 / 不覆盖 / 幂等 / 不造凭据 / 包内安装判定 / 静默输出。
10
+ * 用法:node scaffold.selftest.js
11
+ * ---------------------------------------------------------------------------
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const paths = require('./paths');
18
+ const scaffold = require('./scaffold');
19
+
20
+ let failed = 0;
21
+ const tmpDirs = [];
22
+
23
+ function check(name, ok, detail) {
24
+ if (ok) console.log(` OK ${name}`);
25
+ else { failed++; console.log(` FAIL ${name}${detail === undefined ? '' : ` → ${detail}`}`); }
26
+ }
27
+
28
+ function mkTmp(tag) {
29
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), `cp-scaffold-${tag}-`));
30
+ tmpDirs.push(d);
31
+ return d;
32
+ }
33
+
34
+ function cleanup() {
35
+ for (const d of tmpDirs) {
36
+ try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* 忽略 */ }
37
+ }
38
+ }
39
+
40
+ const read = (p) => fs.readFileSync(p, 'utf8');
41
+
42
+ console.log('[1] 空目录(重定位):应补齐目录与观察池');
43
+ {
44
+ const H = mkTmp('empty');
45
+ paths.setHome(H);
46
+ const r = scaffold.ensureWorkspace();
47
+
48
+ for (const [k, p] of Object.entries({
49
+ data: paths.dataDir(), reports: paths.reportsDir(),
50
+ 'eod/data': paths.eodDataDir(), 'eod/reports': paths.eodReportsDir(), db: path.dirname(paths.localDb()),
51
+ })) {
52
+ check(`创建目录 ${k}`, fs.existsSync(p), p);
53
+ }
54
+ check('candidates.json 已从示例模板生成', fs.existsSync(paths.candidatesFile()), paths.candidatesFile());
55
+ const j = JSON.parse(read(paths.candidatesFile()));
56
+ check('生成的观察池结构正确(stocks 非空)', Array.isArray(j.stocks) && j.stocks.length > 0, `stocks=${j.stocks && j.stocks.length}`);
57
+ check('生成的观察池带示例说明', /示例/.test(String(j.note || '')), j.note);
58
+ check('createdFiles 记录 1 项', r.createdFiles.length === 1, String(r.createdFiles.length));
59
+ check('relocated=true(HOME 不是包根)', r.relocated === true, String(r.relocated));
60
+ check('source=setHome()', r.source === 'setHome()', r.source);
61
+ check('installed=false(临时目录不在 node_modules 下)', r.installed === false, String(r.installed));
62
+ }
63
+
64
+ console.log('\n[2] 绝不写入含凭据的配置文件');
65
+ {
66
+ const H = mkTmp('creds');
67
+ paths.setHome(H);
68
+ scaffold.ensureWorkspace();
69
+ check('未创建 notify_config.json', !fs.existsSync(paths.notifyConfigFile()), paths.notifyConfigFile());
70
+ check('未创建 db/.env', !fs.existsSync(paths.dbEnvFile()), paths.dbEnvFile());
71
+ const r = scaffold.ensureWorkspace();
72
+ check('两份凭据以「提示」形式给出', r.hints.length === 2, JSON.stringify(r.hints.map((h) => path.basename(h.to))));
73
+ check('提示里带可复制的命令', scaffold.renderHints(r).some((l) => /^\s+cp "/.test(l)), 'no cp line');
74
+ }
75
+
76
+ console.log('\n[3] 绝不覆盖已存在的文件(向后兼容的核心)');
77
+ {
78
+ const H = mkTmp('keep');
79
+ paths.setHome(H);
80
+ fs.mkdirSync(H, { recursive: true });
81
+ const mine = JSON.stringify({ name: '我的观察池', stocks: [{ code: 'sh600519', name: '贵州茅台', sector: '食品饮料' }] }, null, 2);
82
+ fs.writeFileSync(paths.candidatesFile(), mine);
83
+ fs.writeFileSync(paths.notifyConfigFile(), '{"wechat":{"key":"MY_REAL_KEY"}}');
84
+
85
+ const r = scaffold.ensureWorkspace();
86
+ check('已有 candidates.json 内容未被改写', read(paths.candidatesFile()) === mine, 'content changed');
87
+ check('已有 candidates.json 未被重新生成', r.createdFiles.length === 0, String(r.createdFiles.length));
88
+ check('已有 notify_config.json 原样保留', /MY_REAL_KEY/.test(read(paths.notifyConfigFile())), 'changed');
89
+ check('已有的那份不再出现在提示里', !r.hints.some((h) => h.to === paths.notifyConfigFile()), 'still hinted');
90
+ }
91
+
92
+ console.log('\n[4] 幂等:重复调用零副作用');
93
+ {
94
+ const H = mkTmp('idem');
95
+ paths.setHome(H);
96
+ const a = scaffold.ensureWorkspace();
97
+ const before = read(paths.candidatesFile());
98
+ const b = scaffold.ensureWorkspace();
99
+ const c = scaffold.ensureWorkspace();
100
+ check('第二次调用不创建任何目录', b.createdDirs.length === 0, String(b.createdDirs.length));
101
+ check('第二次调用不创建任何文件', b.createdFiles.length === 0, String(b.createdFiles.length));
102
+ check('第三次调用同样为空', c.createdDirs.length + c.createdFiles.length === 0, 'not empty');
103
+ check('文件内容稳定', read(paths.candidatesFile()) === before, 'content drift');
104
+ check('首次确实创建了东西(对照)', a.createdDirs.length + a.createdFiles.length > 0, 'nothing created at all');
105
+ }
106
+
107
+ console.log('\n[5] 输出:无事发生时保持静默');
108
+ {
109
+ const H = mkTmp('quiet');
110
+ paths.setHome(H);
111
+ scaffold.ensureWorkspace();
112
+ const silent = scaffold.ensureWorkspace();
113
+ check('无变化 → renderReport 为空数组', scaffold.renderReport(silent).length === 0, JSON.stringify(scaffold.renderReport(silent)));
114
+ const lines = [];
115
+ scaffold.autoInit({ log: (s) => lines.push(s) });
116
+ check('无变化 → autoInit 不打任何日志', lines.length === 0, lines.join(' | '));
117
+
118
+ const lines2 = [];
119
+ scaffold.autoInit({ log: (s) => lines2.push(s), init: true });
120
+ check('--init 即使无变化也有输出', lines2.length > 0, 'silent');
121
+ check('--init 输出含「已就绪」', lines2.some((l) => /已就绪/.test(l)), lines2.join(' | '));
122
+ }
123
+
124
+ console.log('\n[6] node_modules 安装场景:应给出重定位警告');
125
+ {
126
+ const H = path.join(mkTmp('nm'), 'node_modules', 'candidate-pool');
127
+ fs.mkdirSync(H, { recursive: true });
128
+ paths.setHome(H);
129
+ const r = scaffold.ensureWorkspace();
130
+ check('installed=true', r.installed === true, String(r.installed));
131
+ const report = scaffold.renderReport(r).join('\n');
132
+ check('报告里出现重定位警告', /node_modules/.test(report) && /--cwd/.test(report), report);
133
+ }
134
+
135
+ console.log('\n[7] 默认(包根)行为:本仓库已有全部文件 → 不创建、不改动');
136
+ {
137
+ paths.setHome(null);
138
+ check('HOME 回到包根', paths.home() === paths.PKG_ROOT, paths.home());
139
+ const before = fs.existsSync(paths.candidatesFile()) ? read(paths.candidatesFile()) : null;
140
+ const r = scaffold.ensureWorkspace();
141
+ check('未创建任何文件(仓库里都齐了)', r.createdFiles.length === 0, JSON.stringify(r.createdFiles.map((f) => f.to)));
142
+ if (before !== null) {
143
+ check('仓库里的 candidates.json 未被改动', read(paths.candidatesFile()) === before, 'changed');
144
+ check('仓库里的 candidates.json 不是示例模板', !/示例模板/.test(read(paths.candidatesFile())), 'overwritten by template');
145
+ } else {
146
+ check('仓库里的 candidates.json 跳过(不存在,不适用)', true);
147
+ }
148
+ check('PKG_ROOT 下没有 node_modules 判定', scaffold.looksInstalled(paths.PKG_ROOT) === false, 'misdetected');
149
+ }
150
+
151
+ cleanup();
152
+ console.log(failed ? `\n自我检查失败:${failed} 项` : '\n全部通过');
153
+ process.exit(failed ? 1 : 0);
package/time.js ADDED
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+ // 统一时间处理:所有业务时间一律走 dayjs(全局规则)。
3
+ // 业务时区锁定 Asia/Shanghai,落库/展示统一用北京时间字符串(YYYY-MM-DD HH:mm:ss,无 Z),
4
+ // 根治裸 new Date().toISOString() 带来的 +8h 时差问题。
5
+ const dayjs = require('dayjs');
6
+ const utc = require('dayjs/plugin/utc');
7
+ const tz = require('dayjs/plugin/timezone');
8
+ dayjs.extend(utc);
9
+ dayjs.extend(tz);
10
+ dayjs.tz.setDefault('Asia/Shanghai');
11
+
12
+ // 当前北京时间字符串(YYYY-MM-DD HH:mm:ss),无 Z
13
+ function now() {
14
+ return dayjs.tz().format('YYYY-MM-DD HH:mm:ss');
15
+ }
16
+
17
+ module.exports = { now, dayjs };