@kamuira/stock-analyzer 1.3.2 → 1.3.3

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.
Files changed (2) hide show
  1. package/analyze.js +141 -72
  2. package/package.json +1 -1
package/analyze.js CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  const http = require('http');
11
11
  const https = require('https');
12
+ const tls = require('tls');
13
+ const { execFile } = require('child_process');
12
14
  const { SMA } = require('./indicators');
13
15
  const { computeScore } = require('./scoring');
14
16
 
@@ -16,6 +18,99 @@ const { computeScore } = require('./scoring');
16
18
  // 不加超时会让 Promise 永久挂起,前端一直转圈。
17
19
  const REQUEST_TIMEOUT = 8000;
18
20
 
21
+ /**
22
+ * 走代理的 https GET(用于台股 Yahoo/TWSE 这类境外源)。
23
+ *
24
+ * 背景:国内 IP 直连 Yahoo finance 接口会被地区屏蔽(返回 403 的 <html lang="zh">),
25
+ * 而 Node 的 https.get 默认 *不读* HTTPS_PROXY 环境变量,所以即便用户开了
26
+ * 代理(Clash 等)台股也拿不到数据。这里在检测到 https_proxy/HTTPS_PROXY 时,
27
+ * 用 HTTP CONNECT 建隧道再做 TLS,纯 Node、无依赖;未设代理则退化为普通直连。
28
+ *
29
+ * @returns {import('http').ClientRequest} 与 https.get 一致,可被 withTimeout 包裹
30
+ */
31
+ function proxiedGet(reqOptions, cb) {
32
+ const proxy = process.env.https_proxy || process.env.HTTPS_PROXY
33
+ || process.env.all_proxy || process.env.ALL_PROXY;
34
+ if (!proxy) return https.get(reqOptions, cb);
35
+
36
+ const pu = new URL(proxy);
37
+ const host = reqOptions.hostname || reqOptions.host;
38
+ const port = reqOptions.port || 443;
39
+ const auth = pu.username
40
+ ? { 'Proxy-Authorization': 'Basic ' + Buffer.from(
41
+ decodeURIComponent(pu.username) + ':' + decodeURIComponent(pu.password)).toString('base64') }
42
+ : {};
43
+
44
+ const opts = Object.assign({}, reqOptions, {
45
+ agent: false,
46
+ createConnection(_o, done) {
47
+ const c = http.request({
48
+ host: pu.hostname, port: pu.port || 80, method: 'CONNECT',
49
+ path: `${host}:${port}`, headers: auth,
50
+ });
51
+ c.on('connect', (res, socket) => {
52
+ if (res.statusCode !== 200) { done(new Error(`代理 CONNECT 失败: ${res.statusCode}`)); return; }
53
+ const tlsSock = tls.connect({ socket, servername: host }, () => done(null, tlsSock));
54
+ tlsSock.on('error', done);
55
+ });
56
+ c.on('error', done);
57
+ c.setTimeout(REQUEST_TIMEOUT, () => c.destroy(new Error('代理连接超时')));
58
+ c.end();
59
+ },
60
+ });
61
+ return https.get(opts, cb);
62
+ }
63
+
64
+ /** proxiedGet 的 Promise 版,拿整段响应文本(供台股源的回退路径用) */
65
+ function proxiedText(url) {
66
+ return new Promise((resolve, reject) => {
67
+ const u = new URL(url);
68
+ withTimeout(proxiedGet({
69
+ hostname: u.hostname, path: u.pathname + u.search,
70
+ headers: { 'User-Agent': 'Mozilla/5.0', Accept: '*/*' },
71
+ }, (res) => {
72
+ const chunks = [];
73
+ res.on('data', c => chunks.push(c));
74
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
75
+ }), reject);
76
+ });
77
+ }
78
+
79
+ /**
80
+ * 用系统 curl 取文本。台股的 Yahoo / TWSE 会按 TLS 指纹(JA3)歧视 Node 的握手
81
+ * (Node 直连/隧道都被 403 或挂起),而 curl 的指纹能通过;curl 还会自动读取
82
+ * https_proxy 环境变量,正好解决国内访问境外源需走代理的问题。
83
+ * curl 不存在时(罕见)抛 ENOENT,由 twFetch 回退到 Node 路径。
84
+ */
85
+ function curlText(url) {
86
+ return new Promise((resolve, reject) => {
87
+ // 经代理访问境外源首连常慢/偶发 SSL 重置(exit 35),故给足超时并自动重试。
88
+ execFile('curl', ['-s', '--compressed', '--connect-timeout', '8', '--max-time', '20',
89
+ '--retry', '3', '--retry-delay', '1', '--retry-all-errors',
90
+ '-A', 'Mozilla/5.0', '-H', 'Accept: */*', url],
91
+ { maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
92
+ if (err) { reject(err); return; }
93
+ if (!stdout) { reject(new Error('curl 返回空响应')); return; }
94
+ resolve(stdout);
95
+ });
96
+ });
97
+ }
98
+
99
+ /** 台股取数:优先 curl(过指纹+走代理),无 curl 时回退 Node 代理隧道 */
100
+ async function twFetch(url) {
101
+ let lastErr;
102
+ for (let attempt = 0; attempt < 3; attempt++) {
103
+ if (attempt > 0) await new Promise(r => setTimeout(r, 600));
104
+ try {
105
+ return await curlText(url);
106
+ } catch (e) {
107
+ if (e && e.code === 'ENOENT') return proxiedText(url); // 环境无 curl,回退 Node 隧道
108
+ lastErr = e; // 多为经代理首连的偶发 SSL 重置,稍后重试
109
+ }
110
+ }
111
+ throw lastErr;
112
+ }
113
+
19
114
  /**
20
115
  * 给一个 http(s) 请求挂上超时和错误处理。
21
116
  * @param {import('http').ClientRequest} req http(s).get 返回的请求对象
@@ -144,82 +239,56 @@ function collect(res, code, resolve, reject) {
144
239
 
145
240
  // ==================== 台股数据 ====================
146
241
 
147
- function fetchTWRealtime(code) {
148
- return new Promise((resolve, reject) => {
149
- const num = code.replace(/^tw/i, '');
150
- const exCh = `tse_${num}.tw|otc_${num}.tw`;
151
- const url = `/stock/api/getStockInfo.jsp?ex_ch=${exCh}&_=${Date.now()}`;
152
- withTimeout(https.get({ hostname: 'mis.twse.com.tw', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
153
- const chunks = [];
154
- res.on('data', c => chunks.push(c));
155
- res.on('end', () => {
156
- try {
157
- const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
158
- if (!json.msgArray || json.msgArray.length === 0) { resolve([]); return; }
159
- const results = [];
160
- for (const item of json.msgArray) {
161
- if (!item.z || item.z === '-') continue;
162
- const price = parseFloat(item.z);
163
- const yesterdayClose = parseFloat(item.y);
164
- results.push({
165
- code: `tw${item.c}`, name: item.n, price,
166
- open: parseFloat(item.o) || price, high: parseFloat(item.h) || price,
167
- low: parseFloat(item.l) || price, yesterdayClose,
168
- change: +(price - yesterdayClose).toFixed(2),
169
- changePct: yesterdayClose > 0 ? +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2) : 0,
170
- volume: Math.round(parseInt(item.v) || 0), amount: 0,
171
- time: `${item.d} ${item.t || ''}`,
172
- });
173
- }
174
- resolve(results);
175
- } catch (e) { reject(e); }
176
- });
177
- }), reject);
178
- });
242
+ async function fetchTWRealtime(code) {
243
+ const num = code.replace(/^tw/i, '');
244
+ const exCh = `tse_${num}.tw|otc_${num}.tw`;
245
+ const url = `https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=${exCh}&_=${Date.now()}`;
246
+ const json = JSON.parse(await twFetch(url));
247
+ if (!json.msgArray || json.msgArray.length === 0) return [];
248
+ const results = [];
249
+ for (const item of json.msgArray) {
250
+ if (!item.z || item.z === '-') continue;
251
+ const price = parseFloat(item.z);
252
+ const yesterdayClose = parseFloat(item.y);
253
+ results.push({
254
+ code: `tw${item.c}`, name: item.n, price,
255
+ open: parseFloat(item.o) || price, high: parseFloat(item.h) || price,
256
+ low: parseFloat(item.l) || price, yesterdayClose,
257
+ change: +(price - yesterdayClose).toFixed(2),
258
+ changePct: yesterdayClose > 0 ? +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2) : 0,
259
+ volume: Math.round(parseInt(item.v) || 0), amount: 0,
260
+ time: `${item.d} ${item.t || ''}`,
261
+ });
262
+ }
263
+ return results;
179
264
  }
180
265
 
181
- function fetchTWHistory(code, days = 120) {
182
- return new Promise((resolve, reject) => {
183
- const num = code.replace(/^tw/i, '');
184
- const symbol = `${num}.TW`;
185
- const period2 = Math.floor(Date.now() / 1000);
186
- const period1 = period2 - Math.floor(days * 24 * 60 * 60 * 1.5);
187
- const url = `/v8/finance/chart/${symbol}?period1=${period1}&period2=${period2}&interval=1d`;
188
- withTimeout(https.get({ hostname: 'query1.finance.yahoo.com', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
189
- if (res.statusCode === 301 || res.statusCode === 302) {
190
- withTimeout(https.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
191
- collectTWHist(res2, resolve, reject);
192
- }), reject);
193
- return;
194
- }
195
- collectTWHist(res, resolve, reject);
196
- }), reject);
197
- });
266
+ async function fetchTWHistory(code, days = 120) {
267
+ const num = code.replace(/^tw/i, '');
268
+ const symbol = `${num}.TW`;
269
+ const period2 = Math.floor(Date.now() / 1000);
270
+ const period1 = period2 - Math.floor(days * 24 * 60 * 60 * 1.5);
271
+ const url = `https://query1.finance.yahoo.com/v8/finance/chart/${symbol}?period1=${period1}&period2=${period2}&interval=1d`;
272
+ return parseTWHist(await twFetch(url));
198
273
  }
199
274
 
200
- function collectTWHist(res, resolve, reject) {
201
- const chunks = [];
202
- res.on('data', c => chunks.push(c));
203
- res.on('end', () => {
204
- try {
205
- const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
206
- const result = json.chart && json.chart.result && json.chart.result[0];
207
- if (!result || !result.timestamp) { resolve([]); return; }
208
- const ts = result.timestamp, q = result.indicators.quote[0];
209
- const klines = [];
210
- for (let i = 0; i < ts.length; i++) {
211
- if (q.close[i] === null) continue;
212
- const d = new Date(ts[i] * 1000);
213
- klines.push({
214
- date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
215
- open: +(q.open[i]||0).toFixed(2), close: +(q.close[i]||0).toFixed(2),
216
- high: +(q.high[i]||0).toFixed(2), low: +(q.low[i]||0).toFixed(2),
217
- volume: Math.round((q.volume[i]||0)/1000),
218
- });
219
- }
220
- resolve(klines);
221
- } catch (e) { reject(e); }
222
- });
275
+ function parseTWHist(body) {
276
+ const json = JSON.parse(body);
277
+ const result = json.chart && json.chart.result && json.chart.result[0];
278
+ if (!result || !result.timestamp) return [];
279
+ const ts = result.timestamp, q = result.indicators.quote[0];
280
+ const klines = [];
281
+ for (let i = 0; i < ts.length; i++) {
282
+ if (q.close[i] === null) continue;
283
+ const d = new Date(ts[i] * 1000);
284
+ klines.push({
285
+ date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
286
+ open: +(q.open[i]||0).toFixed(2), close: +(q.close[i]||0).toFixed(2),
287
+ high: +(q.high[i]||0).toFixed(2), low: +(q.low[i]||0).toFixed(2),
288
+ volume: Math.round((q.volume[i]||0)/1000),
289
+ });
290
+ }
291
+ return klines;
223
292
  }
224
293
 
225
294
  // ==================== 大盘环境 ====================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kamuira/stock-analyzer",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "preferGlobal": true,
5
5
  "description": "A股/台股综合分析工具 - 技术面+估值+基本面+消息面四维评分、批量排名、含成本回测、买卖建议",
6
6
  "main": "server.js",