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/db/d1client.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // Cloudflare D1 HTTP API 客户端(供本机脚本 / CI 写入,非 Workers 内调用)
3
+ // 凭据来源(优先级从高到低):
4
+ // 1) 真实环境变量 CF_ACCOUNT_ID / CF_D1_DATABASE_ID / CF_API_TOKEN(CI Secrets / 系统环境变量)
5
+ // 2) db/.env —— 由 dotenv 加载,默认**不覆盖**上面已存在的值
6
+ // 仅在真正写入时才校验凭据;dry-run 不调用本模块。
7
+ const path = require('path');
8
+ const paths = require('../paths');
9
+ // 凭据文件跟随工作目录(见 paths.js):默认 <仓库根>/db/.env
10
+ require('dotenv').config({ path: paths.dbEnvFile(), quiet: true });
11
+
12
+ function cfg() {
13
+ const ACCOUNT_ID = process.env.CF_ACCOUNT_ID;
14
+ const DB_ID = process.env.CF_D1_DATABASE_ID;
15
+ const TOKEN = process.env.CF_API_TOKEN;
16
+ if (!ACCOUNT_ID || !DB_ID || !TOKEN) return null;
17
+ return { ACCOUNT_ID, DB_ID, TOKEN };
18
+ }
19
+ exports.cfg = cfg;
20
+
21
+ function endpoint() {
22
+ const c = cfg();
23
+ if (!c) throw new Error('缺少 Cloudflare 凭据:请设置 CF_ACCOUNT_ID / CF_D1_DATABASE_ID / CF_API_TOKEN');
24
+ return c;
25
+ }
26
+
27
+ async function post(path, body) {
28
+ const c = endpoint();
29
+ const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${c.ACCOUNT_ID}/d1/database/${c.DB_ID}${path}`, {
30
+ method: 'POST',
31
+ headers: { Authorization: `Bearer ${c.TOKEN}`, 'Content-Type': 'application/json' },
32
+ body: JSON.stringify(body),
33
+ });
34
+ const j = await res.json();
35
+ if (!j.success) throw new Error('CF D1 API 错误: ' + JSON.stringify(j.errors || j.messages || j));
36
+ return j.result;
37
+ }
38
+
39
+ // 单条语句(DDL / 查询 / 单插)
40
+ async function exec(sql) {
41
+ const r = await post('/query', { sql });
42
+ return (r && r[0]) ? r[0] : r;
43
+ }
44
+ // 单条查询,返回结果数组
45
+ async function query(sql, params = []) {
46
+ const r = await post('/query', { sql, params });
47
+ return (r && r[0] && r[0].results) || [];
48
+ }
49
+ // 批量语句(每条语句形如 { sql, params })
50
+ // 说明:Cloudflare D1 的 /execute 路由在本账号/数据库稳定返回 404 "Route not found",
51
+ // 而 /query 的批量 { statements:[...] } 入参也被拒绝(要求单个 sql)。
52
+ // 因此改为走 /query 逐条提交 { sql, params }(与 query() 同一可用路径),
53
+ // 以有界并发(CONC)提交,避免串行过慢、又不过度压测 API 限流。
54
+ const BATCH_CONC = 10;
55
+ async function batch(statements) {
56
+ const out = [];
57
+ for (let i = 0; i < statements.length; i += BATCH_CONC) {
58
+ const slice = statements.slice(i, i + BATCH_CONC);
59
+ const results = await Promise.all(slice.map(s =>
60
+ post('/query', { sql: s.sql, params: s.params || [] })
61
+ ));
62
+ results.forEach(r => { if (Array.isArray(r)) out.push(...r); });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ exports.exec = exec;
68
+ exports.query = query;
69
+ exports.batch = batch;
package/db/ddl.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+ /*
3
+ * db/ddl.js —— DDL 的唯一读取入口(schema.sql 是唯一真相源)
4
+ * ---------------------------------------------------------------------------
5
+ * 背景(2026-09-21 事故):
6
+ * 同一份建表语句曾被复制到 3 个地方 —— db/schema.sql、
7
+ * eod/lib/store_d1.js::ensureTables、db/persist.js::ensureColumns,彼此没有约束关系。
8
+ * 而两条「自动应用」路径都是坏的:
9
+ * · 本地:db/sqlite_client.js 只在【缺 run_batch 表】时才应用 schema。
10
+ * 库一旦早已存在,后来新增的 tail_run / tail_pick 就永远不会被创建。
11
+ * · 远程:压根没有自动应用路径,只靠人工 `wrangler d1 execute`。
12
+ * 结果:local.db 与线上 D1 都没有尾盘两张表 → /api/tail/* 全部
13
+ * `no such table` → 被 onError 兜成 HTTP 500(四个页面全挂)。
14
+ *
15
+ * 现在的约定:
16
+ * 1. schema.sql 只写「最终想要的形状」,全部 IF NOT EXISTS,**可重复执行**;
17
+ * 2. 任何需要建表/建索引的地方都从这里取语句,不再各写一份;
18
+ * 3. 需要「改已存在的表」(SQLite 的 CREATE TABLE IF NOT EXISTS 不会补列)时,
19
+ * 写成 db/migrations/NNN-描述.sql,由 db/migrate.js 按文件名顺序执行一次并记录。
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const SCHEMA_FILE = path.join(__dirname, 'schema.sql');
26
+ const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
27
+
28
+ /**
29
+ * 把 SQL 文本切成单条语句。
30
+ * - 去掉 `--` 注释(本项目 schema.sql 大量使用行尾内联注释,需一并清掉);
31
+ * - 按 `;` 切分;本项目 DDL 不含「字符串字面量里带分号」的情况,故简单切分足够。
32
+ *
33
+ * 必须切分的原因:Cloudflare D1 的 /query 路由只接受**单条**语句。
34
+ *
35
+ * @param {string} sql
36
+ * @returns {string[]}
37
+ */
38
+ function splitStatements(sql) {
39
+ return String(sql)
40
+ .split(/\r?\n/)
41
+ .map((line) => line.replace(/--.*$/, ''))
42
+ .join('\n')
43
+ .split(';')
44
+ .map((s) => s.trim())
45
+ .filter(Boolean);
46
+ }
47
+
48
+ /** schema.sql 的原始文本(用于计算 hash / 判断是否变化)。 */
49
+ function readSchema() {
50
+ return fs.readFileSync(SCHEMA_FILE, 'utf8');
51
+ }
52
+
53
+ /** schema.sql 切分后的语句数组(建表 + 建索引,全部幂等)。 */
54
+ function loadStatements() {
55
+ return splitStatements(readSchema());
56
+ }
57
+
58
+ /**
59
+ * 增量迁移文件(db/migrations/NNN-*.sql),按文件名升序。
60
+ * 目录不存在时返回空数组(本项目当前没有需要 ALTER 的变更)。
61
+ * @returns {Array<{name: string, statements: string[]}>}
62
+ */
63
+ function loadMigrations() {
64
+ if (!fs.existsSync(MIGRATIONS_DIR)) return [];
65
+ return fs
66
+ .readdirSync(MIGRATIONS_DIR)
67
+ .filter((f) => f.endsWith('.sql'))
68
+ .sort()
69
+ .map((name) => ({
70
+ name,
71
+ statements: splitStatements(fs.readFileSync(path.join(MIGRATIONS_DIR, name), 'utf8')),
72
+ }));
73
+ }
74
+
75
+ module.exports = {
76
+ SCHEMA_FILE,
77
+ MIGRATIONS_DIR,
78
+ splitStatements,
79
+ readSchema,
80
+ loadStatements,
81
+ loadMigrations,
82
+ };
package/db/diff.js ADDED
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+ /*
3
+ * db/diff.js —— 结构差异识别(声明式:期望结构 vs 库中实际结构)
4
+ * ---------------------------------------------------------------------------
5
+ * 为什么需要它:
6
+ * db/migrate.js 若只是把 schema.sql 整份重放,`CREATE TABLE IF NOT EXISTS`
7
+ * 对【已存在的表】是空操作 —— 也就是**新增字段永远不会生效**,只有「新建整表」才生效。
8
+ * 而「给老表加字段」恰恰是最常见的结构变更(本项目已手工加过 run_at / vol_ratio)。
9
+ *
10
+ * 于是把「识别」与「执行」分开:
11
+ * 本文件只做**纯函数**解析与比对(不碰数据库,可单测);
12
+ * 由 db/migrate.js 决定怎么执行、以及哪些差异必须人工写迁移文件。
13
+ *
14
+ * 能自动处理的(安全、可逆性无要求):
15
+ * - 期望里有、库里没有的【表】 → CREATE TABLE
16
+ * - 期望里有、库里没有的【列】 → ALTER TABLE ... ADD COLUMN(受 SQLite 限制,见 unsafeReason)
17
+ * - 期望里有、库里没有的【索引】 → CREATE INDEX
18
+ *
19
+ * 只识别、不擅自执行的(必须人工写 db/migrations/NNN-*.sql):
20
+ * - 列定义变了(改类型 / 改约束) → SQLite 不支持直接改,需重建表
21
+ * - 列被删 / 表被删 → 涉及数据丢弃,必须人工确认
22
+ * - 不满足 ALTER 限制的新列 → 见 unsafeReason 的返回原因
23
+ */
24
+
25
+ const { splitStatements } = require('./ddl');
26
+
27
+ /** D1 内部表(如 _cf_KV)与 sqlite 内部表不参与比对,避免每次运行都刷警告。 */
28
+ const INTERNAL_TABLE = /^(sqlite_|_cf_)/i;
29
+
30
+ /** 取「从 openIdx 处的左括号」到匹配右括号之间的内容。 */
31
+ function extractParenBody(sql, openIdx) {
32
+ let depth = 0;
33
+ for (let i = openIdx; i < sql.length; i++) {
34
+ const ch = sql[i];
35
+ if (ch === '(') depth++;
36
+ else if (ch === ')') {
37
+ depth--;
38
+ if (depth === 0) return sql.slice(openIdx + 1, i);
39
+ }
40
+ }
41
+ return sql.slice(openIdx + 1);
42
+ }
43
+
44
+ /** 按「顶层逗号」切分(跳过括号内的逗号,如 PRIMARY KEY (a, b))。 */
45
+ function splitTopLevel(body) {
46
+ const out = [];
47
+ let depth = 0;
48
+ let cur = '';
49
+ for (const ch of body) {
50
+ if (ch === '(') depth++;
51
+ else if (ch === ')') depth--;
52
+ if (ch === ',' && depth === 0) {
53
+ out.push(cur);
54
+ cur = '';
55
+ continue;
56
+ }
57
+ cur += ch;
58
+ }
59
+ if (cur.trim()) out.push(cur);
60
+ return out;
61
+ }
62
+
63
+ /** 归一化用于比对(忽略空白与大小写的差异)。 */
64
+ function normalize(s) {
65
+ return String(s).replace(/\s+/g, ' ').trim().toUpperCase();
66
+ }
67
+
68
+ /**
69
+ * 标识符:`foo` / "foo" / [foo] / foo 四种写法都要认。
70
+ * 为什么必须处理:`sqlite_master.sql` 存的是**建表原文**,若某侧建表时带了引号
71
+ * (wrangler / D1 控制台 / 手工执行的 DDL 常有),而解析规则只认裸标识符,
72
+ * 结果会是「列被静默丢弃」→ 误判为缺列 → ALTER 报 duplicate column name。
73
+ * 带上引号消除这个隐患,两侧写法不同也能正确比对。
74
+ */
75
+ const IDENT = `(?:["\`\\[]([^"\`\\]]+)["\`\\]]|([A-Za-z_]\\w*))`;
76
+
77
+ /** 从 IDENT 的匹配结果里取名字(去掉引号)。 */
78
+ function identName(m, offset) {
79
+ return m[offset + 1] != null ? m[offset + 1] : m[offset + 2];
80
+ }
81
+
82
+ /** 该列定义能否用 `ALTER TABLE ADD COLUMN` 安全追加;不能则给出原因。 */
83
+ function unsafeReason(def) {
84
+ const d = normalize(def);
85
+ if (/PRIMARY\s+KEY/.test(d)) return '主键列无法用 ALTER TABLE 追加';
86
+ if (/\bUNIQUE\b/.test(d)) return 'UNIQUE 列无法用 ALTER TABLE 追加';
87
+ if (/AUTOINCREMENT/.test(d)) return 'AUTOINCREMENT 无法用 ALTER TABLE 追加';
88
+ if (/\bNOT\s+NULL\b/.test(d) && !/\bDEFAULT\b/.test(d)) return 'NOT NULL 却没有 DEFAULT,既有行无法补值';
89
+ return null;
90
+ }
91
+
92
+ function parseTableBody(body) {
93
+ const columns = new Map();
94
+ const constraints = [];
95
+ for (const raw of splitTopLevel(body)) {
96
+ const part = raw.trim();
97
+ if (!part) continue;
98
+ // 表级约束(PRIMARY KEY / UNIQUE / FOREIGN KEY / CHECK / CONSTRAINT ...)不是列
99
+ if (/^(PRIMARY\s+KEY|UNIQUE|FOREIGN\s+KEY|CHECK|CONSTRAINT)\b/i.test(part)) {
100
+ constraints.push(normalize(part));
101
+ continue;
102
+ }
103
+ const m = new RegExp(`^${IDENT}\\s+([\\s\\S]+)$`).exec(part);
104
+ if (m) columns.set(identName(m, 0), m[3].trim());
105
+ }
106
+ return { columns, constraints };
107
+ }
108
+
109
+ /**
110
+ * 解析 DDL 文本 → { tables, indexes }。
111
+ * 只识别 CREATE TABLE / CREATE INDEX(本项目 DDL 仅用这两类)。
112
+ * @param {string} sqlText schema.sql 全文,或 sqlite_master.sql 拼接而成的文本
113
+ */
114
+ function parseObjects(sqlText) {
115
+ const tables = new Map();
116
+ const indexes = new Map();
117
+ for (const stmt of splitStatements(sqlText)) {
118
+ const t = new RegExp(
119
+ `^\\s*CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${IDENT}\\s*\\(`,
120
+ 'i',
121
+ ).exec(stmt);
122
+ if (t) {
123
+ const name = identName(t, 0);
124
+ const body = extractParenBody(stmt, t.index + t[0].length - 1);
125
+ tables.set(name, { name, ddl: stmt, ...parseTableBody(body) });
126
+ continue;
127
+ }
128
+ const i = new RegExp(
129
+ `^\\s*CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${IDENT}\\s+ON\\s+${IDENT}`,
130
+ 'i',
131
+ ).exec(stmt);
132
+ if (i) {
133
+ const iname = identName(i, 0);
134
+ indexes.set(iname, { name: iname, table: identName(i, 2), ddl: stmt });
135
+ }
136
+ }
137
+ return { tables, indexes };
138
+ }
139
+
140
+ /**
141
+ * 比对期望结构与实际结构。
142
+ * @returns {{addTables: Array, addColumns: Array, unsafeColumns: Array,
143
+ * changedColumns: Array, addIndexes: Array,
144
+ * droppedTables: string[], droppedColumns: Array}}
145
+ */
146
+ function diffSchema(desired, actual) {
147
+ const addTables = [];
148
+ const addColumns = [];
149
+ const unsafeColumns = [];
150
+ const changedColumns = [];
151
+ const addIndexes = [];
152
+ const droppedTables = [];
153
+ const droppedColumns = [];
154
+
155
+ for (const [tname, dt] of desired.tables) {
156
+ const at = actual.tables.get(tname);
157
+ if (!at) {
158
+ addTables.push({ table: tname, ddl: dt.ddl });
159
+ continue;
160
+ }
161
+ for (const [cname, cdef] of dt.columns) {
162
+ const acdef = at.columns.get(cname);
163
+ if (acdef === undefined) {
164
+ const reason = unsafeReason(cdef);
165
+ (reason ? unsafeColumns : addColumns).push({ table: tname, column: cname, def: cdef, reason });
166
+ } else if (normalize(cdef) !== normalize(acdef)) {
167
+ changedColumns.push({ table: tname, column: cname, desired: cdef, actual: acdef });
168
+ }
169
+ }
170
+ for (const cname of at.columns.keys()) {
171
+ if (!dt.columns.has(cname)) droppedColumns.push({ table: tname, column: cname });
172
+ }
173
+ }
174
+
175
+ for (const tname of actual.tables.keys()) {
176
+ if (!desired.tables.has(tname) && !INTERNAL_TABLE.test(tname)) droppedTables.push(tname);
177
+ }
178
+ for (const [iname, di] of desired.indexes) {
179
+ if (!actual.indexes.has(iname)) addIndexes.push(di);
180
+ }
181
+
182
+ return { addTables, addColumns, unsafeColumns, changedColumns, addIndexes, droppedTables, droppedColumns };
183
+ }
184
+
185
+ /** 差异是否为空(用于判断「结构已经是最新」)。 */
186
+ function isEmptyDiff(d) {
187
+ return (
188
+ d.addTables.length === 0 &&
189
+ d.addColumns.length === 0 &&
190
+ d.addIndexes.length === 0 &&
191
+ d.unsafeColumns.length === 0 &&
192
+ d.changedColumns.length === 0 &&
193
+ d.droppedTables.length === 0 &&
194
+ d.droppedColumns.length === 0
195
+ );
196
+ }
197
+
198
+ /**
199
+ * 差异 → 可执行 DDL(纯函数,不碰数据库)。
200
+ * migrate.js 与 db/diff_selftest.js 共用这一份,保证「测的」就是「跑的」。
201
+ * 只输出安全项;unsafeColumns / changedColumns / dropped* 一律不在此处执行。
202
+ * @returns {string[]}
203
+ */
204
+ function applyStatements(d) {
205
+ const stmts = [];
206
+ for (const t of d.addTables) stmts.push(t.ddl);
207
+ for (const c of d.addColumns) {
208
+ stmts.push(`ALTER TABLE ${c.table} ADD COLUMN ${c.column} ${c.def}`);
209
+ }
210
+ for (const i of d.addIndexes) stmts.push(i.ddl);
211
+ return stmts;
212
+ }
213
+
214
+ module.exports = {
215
+ parseObjects,
216
+ diffSchema,
217
+ isEmptyDiff,
218
+ applyStatements,
219
+ unsafeReason,
220
+ normalize,
221
+ splitTopLevel,
222
+ INTERNAL_TABLE,
223
+ };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /*
3
+ * db/diff_selftest.js —— 结构同步的自测(零依赖,node db/diff_selftest.js)
4
+ * ---------------------------------------------------------------------------
5
+ * 为什么必须有:2026-09-21 的事故本质是「结构同步**静默失效**」——本地永不建新表、
6
+ * 远程靠人工执行,两边都不报错,直到接口 500 才被发现。所以「新增字段能否被识别」
7
+ * 这类能力不能只靠一次人工试验,得有可重跑的回归保护。
8
+ *
9
+ * 覆盖:新增列(可自动 / 不可自动)、新增索引、新增表、删列只报告不执行、
10
+ * 引号标识符、幂等性(跑第二遍必须零差异)。
11
+ * 退出码非 0 表示失败。
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const diffLib = require('./diff');
18
+ const { createClient } = require('./sqlite_client');
19
+
20
+ let failed = 0;
21
+ function check(name, cond, detail) {
22
+ console.log(`${cond ? ' OK ' : ' FAIL'} ${name}${cond || detail === undefined ? '' : ` → ${detail}`}`);
23
+ if (!cond) failed++;
24
+ }
25
+ const names = (arr) => arr.map((x) => x.table + '.' + x.column).sort();
26
+
27
+ // 期望结构:demo 表比库中现状多出 4 列,其中 2 列可自动追加、2 列不允许
28
+ const DESIRED = `
29
+ CREATE TABLE IF NOT EXISTS demo (
30
+ id TEXT NOT NULL PRIMARY KEY,
31
+ a TEXT,
32
+ b REAL,
33
+ c TEXT NOT NULL,
34
+ d TEXT UNIQUE,
35
+ e TEXT NOT NULL DEFAULT 'z'
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_demo_a ON demo (a);
38
+ `;
39
+
40
+ // 库里现状:旧版 demo(无 b/c/d/e)+ 一个 schema 里已删除的列 legacy_col
41
+ const LEGACY = `
42
+ CREATE TABLE legacy_tbl (x TEXT);
43
+ CREATE TABLE demo (id TEXT NOT NULL PRIMARY KEY, a TEXT, legacy_col TEXT);
44
+ INSERT INTO demo (id, a, legacy_col) VALUES ('1', 'hello', 'old');
45
+ `;
46
+
47
+ (async () => {
48
+ const file = path.join(os.tmpdir(), `cp-diff-selftest-${process.pid}.db`);
49
+ const client = createClient(file);
50
+ const readActual = async () => {
51
+ // 与 db/migrate.js::readActual 等价的读结构逻辑
52
+ const t = await client.query("SELECT name, sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL");
53
+ const i = await client.query("SELECT name, sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL");
54
+ return diffLib.parseObjects([...t, ...i].map((r) => `${r.sql};`).join('\n'));
55
+ };
56
+
57
+ try {
58
+ await client.exec(LEGACY);
59
+
60
+ console.log('\n[1] 差异识别:新增列 / 不可自动列 / 新增索引 / 删列');
61
+ const desired = diffLib.parseObjects(DESIRED);
62
+ const d1 = diffLib.diffSchema(desired, await readActual());
63
+ check('可自动新增列 = demo.b, demo.e', JSON.stringify(names(d1.addColumns)) === JSON.stringify(['demo.b', 'demo.e']), JSON.stringify(names(d1.addColumns)));
64
+ check('不可自动新增列 = demo.c, demo.d', JSON.stringify(names(d1.unsafeColumns)) === JSON.stringify(['demo.c', 'demo.d']), JSON.stringify(names(d1.unsafeColumns)));
65
+ check('新增索引 = idx_demo_a', d1.addIndexes.length === 1 && d1.addIndexes[0].name === 'idx_demo_a', JSON.stringify(d1.addIndexes.map((x) => x.name)));
66
+ check('删列只报告不执行 = demo.legacy_col', d1.droppedColumns.length === 1 && d1.droppedColumns[0].column === 'legacy_col', JSON.stringify(names(d1.droppedColumns)));
67
+ check('不给「不允许的列」生成 DDL', !diffLib.applyStatements(d1).some((s) => /ADD COLUMN (c|d)\b/.test(s)));
68
+ check('不给删列生成 DDL', !diffLib.applyStatements(d1).some((s) => /DROP/i.test(s)));
69
+
70
+ console.log('\n[2] 执行差异:DDL 真的能跑通,且存量行不被破坏');
71
+ const stmts = diffLib.applyStatements(d1);
72
+ await client.batch(stmts.map((sql) => ({ sql })));
73
+ const cols = (await client.query('PRAGMA table_info(demo)')).map((r) => r.name);
74
+ check('demo 现含 b/e', cols.includes('b') && cols.includes('e'), cols.join(','));
75
+ const row = (await client.query("SELECT * FROM demo WHERE id='1'"))[0];
76
+ check('存量行 a 保留', row && row.a === 'hello', JSON.stringify(row));
77
+ check('存量行 b 补空值', row && row.b === null, JSON.stringify(row && row.b));
78
+ check("新列 e 取默认值 'z'", row && row.e === 'z', JSON.stringify(row && row.e));
79
+ const idx = (await client.query("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_demo_a'")).length;
80
+ check('索引已建', idx === 1, String(idx));
81
+
82
+ console.log('\n[3] 幂等性:再比一次必须只剩「不可自动」与「删列」');
83
+ const d2 = diffLib.diffSchema(desired, await readActual());
84
+ check('无新增表/列/索引', d2.addTables.length === 0 && d2.addColumns.length === 0 && d2.addIndexes.length === 0, JSON.stringify({ t: d2.addTables.length, c: d2.addColumns.length, i: d2.addIndexes.length }));
85
+ check('DDL 为空(可安全重复跑)', diffLib.applyStatements(d2).length === 0, JSON.stringify(diffLib.applyStatements(d2)));
86
+
87
+ console.log('\n[4] 健壮性:引号/反引号/方括号标识符 & 大小写/空白差异不误报');
88
+ const quoted = diffLib.parseObjects(`
89
+ create table if not exists "demo" (
90
+ "id" text not null primary key,
91
+ \`a\` TEXT,
92
+ [b] REAL,
93
+ c TEXT NOT NULL,
94
+ d TEXT UNIQUE,
95
+ e TEXT NOT NULL DEFAULT 'z'
96
+ );
97
+ CREATE INDEX IF NOT EXISTS "idx_demo_a" ON "demo" ("a");
98
+ `);
99
+ const d3 = diffLib.diffSchema(quoted, await readActual());
100
+ check('引号写法解析出 6 列', quoted.tables.get('demo') && quoted.tables.get('demo').columns.size === 6, quoted.tables.get('demo') && quoted.tables.get('demo').columns.size);
101
+ check('引号/大小写差异不误报为缺列', d3.addColumns.length === 0 && d3.changedColumns.length === 0, JSON.stringify({ add: names(d3.addColumns), chg: names(d3.changedColumns) }));
102
+
103
+ console.log('\n[5] 新增整表');
104
+ const withNew = diffLib.parseObjects(DESIRED + '\nCREATE TABLE IF NOT EXISTS brand_new (k TEXT PRIMARY KEY, v TEXT);');
105
+ const d4 = diffLib.diffSchema(withNew, await readActual());
106
+ check('识别出 brand_new', d4.addTables.length === 1 && d4.addTables[0].table === 'brand_new', JSON.stringify(d4.addTables.map((x) => x.table)));
107
+ await client.batch(diffLib.applyStatements(d4).map((sql) => ({ sql })));
108
+ check('brand_new 已建', (await client.query("SELECT name FROM sqlite_master WHERE type='table' AND name='brand_new'")).length === 1);
109
+ // 注意:demo.legacy_col 是「只报告不执行」的删列项,故第二遍不会 isEmptyDiff 全空;
110
+ // 这里断言的是「可执行项已归零」——即重复运行不会产生任何 DDL。
111
+ const d5 = diffLib.diffSchema(withNew, await readActual());
112
+ check('第二遍无可执行项(DDL 为空)', diffLib.applyStatements(d5).length === 0, JSON.stringify(diffLib.applyStatements(d5)));
113
+ check('第二遍仅剩已声明的人工项(删列)', d5.droppedColumns.length === 1 && d5.unsafeColumns.length === 2, JSON.stringify({ drop: names(d5.droppedColumns), unsafe: names(d5.unsafeColumns) }));
114
+ } finally {
115
+ client.close();
116
+ try { fs.unlinkSync(file); } catch (_) {}
117
+ }
118
+
119
+ console.log(failed ? `\n自我检查失败:${failed} 项` : '\n全部通过');
120
+ process.exit(failed ? 1 : 0);
121
+ })().catch((e) => {
122
+ console.error('[致命]', (e && e.stack) || e);
123
+ process.exit(1);
124
+ });
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ // 股票档案补全:从东方财富 F10 公开接口低频抓取
3
+ // 题材概念 / 地域 / 主营业务 / 最赚钱业务,写入 stock_base(仅填缺失字段)。
4
+ // 用法:
5
+ // node db/enrich_stock_base.js # 补全所有 concepts/region/main_business 为空的票
6
+ // node db/enrich_stock_base.js --test sh600519 # 打印单只原始响应,便于校准字段路径
7
+ // node db/enrich_stock_base.js --all # 强制覆盖已存在的字段
8
+ // 注意:东方财富 F10 端点可能随版本调整,若字段取不到,用 --test 看原始 JSON 后微调下方解析。
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const https = require('https');
12
+ const d1 = require('./d1client');
13
+ const localSqlite = require('./sqlite_client');
14
+ const { now: fmtNow } = require('../time');
15
+
16
+ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
17
+ const REF = 'https://emweb.securities.eastmoney.com/';
18
+
19
+ function emCode(code) {
20
+ const m = String(code).toLowerCase().match(/^(sh|sz|bj)(\d{6})$/);
21
+ if (!m) return null;
22
+ return m[1].toUpperCase() + m[2];
23
+ }
24
+
25
+ function getJSON(url, tries = 3) {
26
+ return new Promise(async (resolve, reject) => {
27
+ let last;
28
+ for (let i = 0; i < tries; i++) {
29
+ if (i) await new Promise(r => setTimeout(r, 800 * i));
30
+ try {
31
+ const buf = await new Promise((res, rej) => {
32
+ https.get(url, { headers: { 'User-Agent': UA, 'Referer': REF } }, r => {
33
+ const cs = []; r.on('data', c => cs.push(c)); r.on('end', () => res(Buffer.concat(cs)));
34
+ }).on('error', rej).on('timeout', () => rej(new Error('timeout')));
35
+ });
36
+ return resolve(JSON.parse(buf.toString('utf8')));
37
+ } catch (e) { last = e; }
38
+ }
39
+ reject(last);
40
+ });
41
+ }
42
+
43
+ // 取公司概况:主营业务 + 地域(省份)
44
+ async function companySurvey(em) {
45
+ const url = `https://emweb.securities.eastmoney.com/PC_HSF10/CompanySurvey/PageAjax?code=${em}`;
46
+ const j = await getJSON(url);
47
+ const R = j && (j.Result || (Array.isArray(j) && j[0] && j[0].Result));
48
+ if (!R) return {};
49
+ const r = Array.isArray(R) ? R[0] : R;
50
+ return { mainBusiness: r.MainBusiness || r.mainBusiness || '', region: r.Province || r.province || '' };
51
+ }
52
+
53
+ // 取主营构成:按毛利率挑「最赚钱业务」
54
+ async function operationsRequired(em) {
55
+ const url = `https://emweb.securities.eastmoney.com/PC_HSF10/OperationsRequired/PageAjax?code=${em}`;
56
+ const j = await getJSON(url);
57
+ const R = j && (j.Result || (Array.isArray(j) && j[0] && j[0].Result));
58
+ if (!R) return {};
59
+ const main = (R.MainOp || R.mainOp || []);
60
+ if (!main.length) return {};
61
+ let best = null;
62
+ for (const it of main) {
63
+ const gm = parseFloat(it.GROSS_MARGIN != null ? it.GROSS_MARGIN : it.grossMargin);
64
+ if (!isNaN(gm) && (!best || gm > best.gm)) best = { gm, name: it.ITEM_NAME || it.itemName, prop: it.PROPORTION != null ? it.PROPORTION : it.proportion };
65
+ }
66
+ if (!best) return {};
67
+ return { topBusiness: `${best.name}(毛利率 ${Number(best.gm).toFixed(1)}%${best.prop != null ? `,营收占比 ${Number(best.prop).toFixed(1)}%` : ''})` };
68
+ }
69
+
70
+ // 取题材概念
71
+ async function concepts(em) {
72
+ const url = `https://emweb.securities.eastmoney.com/PC_HSF10/BusinessAnalysis/PageAjax?code=${em}`;
73
+ const j = await getJSON(url);
74
+ const R = j && (j.Result || (Array.isArray(j) && j[0] && j[0].Result));
75
+ if (!R) return {};
76
+ const arr = R.Concept || R.concept || R.THSConcept || [];
77
+ const list = (Array.isArray(arr) ? arr : []).map(x => x.TITLE || x.title || x.ZTMC || x.conceptName).filter(Boolean);
78
+ if (list.length) return { concepts: list.join('、') };
79
+ // 兜底:一些版本把概念放在 HYSYL(行业)或核心题材字段
80
+ const core = R.CoreConcept || R.coreConcept;
81
+ if (Array.isArray(core)) return { concepts: core.map(x => x.TITLE || x.title).filter(Boolean).join('、') };
82
+ return {};
83
+ }
84
+
85
+ async function enrichOne(code) {
86
+ const em = emCode(code);
87
+ if (!em) return {};
88
+ const [cs, op, cp] = await Promise.allSettled([companySurvey(em), operationsRequired(em), concepts(em)]);
89
+ return {
90
+ mainBusiness: (cs.status === 'fulfilled' ? cs.value.mainBusiness : '') || '',
91
+ region: (cs.status === 'fulfilled' ? cs.value.region : '') || '',
92
+ topBusiness: (op.status === 'fulfilled' ? op.value.topBusiness : '') || '',
93
+ concepts: (cp.status === 'fulfilled' ? cp.value.concepts : '') || '',
94
+ };
95
+ }
96
+
97
+ (async () => {
98
+ const test = process.argv.includes('--test');
99
+ const all = process.argv.includes('--all');
100
+ const isLocal = process.argv.includes('--local');
101
+ const codeArg = (process.argv.find(a => a.startsWith('--code=')) || '').split('=')[1];
102
+ const client = isLocal ? localSqlite.createClient() : d1;
103
+ if (isLocal) console.log(`[enrich] 本地 SQLite 模式 -> ${client.file}`);
104
+
105
+ if (test && codeArg) {
106
+ const r = await enrichOne(codeArg);
107
+ console.log(JSON.stringify(r, null, 2));
108
+ if (client.close) client.close();
109
+ return;
110
+ }
111
+
112
+ const rows = codeArg
113
+ ? [{ code: codeArg }]
114
+ : await client.query(`SELECT code FROM stock_base WHERE ${all ? '1=1' : "concepts IS NULL OR region IS NULL OR main_business IS NULL"}`);
115
+ const codes = rows.map(r => r.code);
116
+ console.log(`待补全 ${codes.length} 只${all ? '(强制覆盖)' : ''}`);
117
+
118
+ let ok = 0, skip = 0;
119
+ for (const code of codes) {
120
+ try {
121
+ const r = await enrichOne(code);
122
+ const has = r.concepts || r.region || r.mainBusiness || r.topBusiness;
123
+ if (!has) { skip++; console.log(` · ${code}: 接口无数据,跳过`); continue; }
124
+ if (test) { console.log(code, JSON.stringify(r)); continue; }
125
+ await client.query(
126
+ 'UPDATE stock_base SET concepts=COALESCE(NULLIF(?,""),concepts), region=COALESCE(NULLIF(?,""),region), main_business=COALESCE(NULLIF(?,""),main_business), top_business=COALESCE(NULLIF(?,""),top_business), updated_at=? WHERE code=?',
127
+ [r.concepts, r.region, r.mainBusiness, r.topBusiness, fmtNow(), code]
128
+ );
129
+ ok++;
130
+ console.log(` ✓ ${code} 概念=${r.concepts || '-'} 地域=${r.region || '-'} 主营=${r.mainBusiness || '-'} 最赚=${r.topBusiness || '-'}`);
131
+ } catch (e) {
132
+ console.log(` ✗ ${code}: ${e.message}`);
133
+ }
134
+ if (!test) await new Promise(r => setTimeout(r, 250)); // 低频,避免触发东财限流
135
+ }
136
+ if (client.close) client.close();
137
+ console.log(`[enrich] 完成:更新 ${ok} 跳过 ${skip}`);
138
+ })().catch(e => { console.error('[enrich] 中止:', e.message); process.exit(1); });