@dshtrading/connector-tencent 0.6.3 → 0.7.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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @dshtrading/connector-tencent
2
2
 
3
- > **状态:已实证(本出口可用)**。报价(`qt.gtimg.cn`)、日/周/月 K(`web.ifzq.gtimg.cn`
4
- > `fqkline`/`hkfqkline`)、分钟 K(`ifzq.gtimg.cn` `mkline`)与批量报价均从本开发出口返回真实数据。
3
+ > **状态:已实证(本出口可用)**。报价(`qt.gtimg.cn`)、日/周/月 K(`ifzq.gtimg.cn`
4
+ > `fqkline`/`hkfqkline`;`web.ifzq` 同族域名已被上游 WAF 封死,只作回退垫底)、分钟 K
5
+ > (`ifzq.gtimg.cn` `mkline`)与批量报价均从本开发出口返回真实数据。
5
6
  > 证据 `spikes/impl-cn-hk/REPORT.md`、`spikes/impl-tencent-minute/EVIDENCE.md`、
6
7
  > `spikes/impl-tencent-quote-batch/EVIDENCE.md`、`spikes/impl-cn-datasource-bench/EVIDENCE.md`。
7
8
 
@@ -31,7 +32,7 @@ dsh-trading **cn+hk 双市场切片**市场连接器:经腾讯公共行情端
31
32
  | 用途 | 端点 |
32
33
  |---|---|
33
34
  | 报价(单只与批量同端点) | `qt.gtimg.cn/q=<wire[,wire…]>`(`q=` 原生支持逗号列表) |
34
- | 日/周/月 K(前复权) | `web.ifzq.gtimg.cn/appstock/app/{fqkline,hkfqkline}/get` |
35
+ | 日/周/月 K(前复权) | `ifzq.gtimg.cn/appstock/app/{fqkline,hkfqkline}/get`(回退链 `ifzq` → `web.ifzq`;后者自 2026-10-23 起被上游 WAF 封死,见 Agent Note `2026-10-23-tencent-kline-host-fallback-and-eastmoney-network-retry`) |
35
36
  | 分钟 K(**仅 cn**) | `ifzq.gtimg.cn/appstock/app/kline/mkline`(host **无 `www.`**,带则 301) |
36
37
  | 名册检索 | `smartbox.gtimg.cn/s3/?t=all&q=` |
37
38
 
package/lib/rest.d.ts CHANGED
@@ -128,7 +128,7 @@ declare function isoWithOffset(epochMs: number, timeZone: string): string;
128
128
  interface TencentRestOptions {
129
129
  /** 覆盖报价 base(测试/反代用),末尾不带斜杠。 */
130
130
  readonly quoteBaseUrl?: string;
131
- /** 覆盖 K 线 base(测试/反代用),末尾不带斜杠。 */
131
+ /** 覆盖 K 线 base(测试/反代用),末尾不带斜杠;注入即取代整条回退链,不掺默认域名。 */
132
132
  readonly klineBaseUrl?: string;
133
133
  /** 覆盖分钟 K 线 base(测试/反代用),末尾不带斜杠。 */
134
134
  readonly mklineBaseUrl?: string;
package/lib/rest.js CHANGED
@@ -437,14 +437,21 @@ function parseQuoteRows(text) {
437
437
  return rows;
438
438
  }
439
439
  const DEFAULT_QUOTE_BASE_URL = "https://qt.gtimg.cn";
440
- const DEFAULT_KLINE_BASE_URL = "https://web.ifzq.gtimg.cn";
440
+ /**
441
+ * 日/周/月 K 的域名回退链(有序、最多两级):2026-10-23 实测 `web.ifzq.gtimg.cn` 被上游
442
+ * WAF 封死(HTTP 501 + `waf.tencent.com/501page.html` 挑战页;绕开本机代理直连
443
+ * 183.47.126.125 仍 501,换 UA/Referer/标的/周期/HTTP 版本全 501),同族 `ifzq.gtimg.cn`
444
+ * 同参数返回 200 且 payload 结构一致(`qfqday`/`qfqweek`)——故同族域名优先、被封域名垫后,
445
+ * 串行各打一次,绝不无限重试。分钟线走 mkline,本就落在同族域名上,不参与回退。
446
+ */
447
+ const DEFAULT_KLINE_BASE_URLS = ["https://ifzq.gtimg.cn", "https://web.ifzq.gtimg.cn"];
441
448
  const DEFAULT_MKLINE_BASE_URL = "https://ifzq.gtimg.cn";
442
449
  const DEFAULT_SEARCH_BASE_URL = "https://smartbox.gtimg.cn";
443
450
  const DEFAULT_TIMEOUT_MS = 1e4;
444
451
  var TencentRestClient = class {
445
452
  #market;
446
453
  #quoteBaseUrl;
447
- #klineBaseUrl;
454
+ #klineBaseUrls;
448
455
  #mklineBaseUrl;
449
456
  #searchBaseUrl;
450
457
  #timeoutMs;
@@ -453,7 +460,7 @@ var TencentRestClient = class {
453
460
  constructor(market, options = {}) {
454
461
  this.#market = market;
455
462
  this.#quoteBaseUrl = options.quoteBaseUrl ?? DEFAULT_QUOTE_BASE_URL;
456
- this.#klineBaseUrl = options.klineBaseUrl ?? DEFAULT_KLINE_BASE_URL;
463
+ this.#klineBaseUrls = options.klineBaseUrl === void 0 ? DEFAULT_KLINE_BASE_URLS : [options.klineBaseUrl];
457
464
  this.#mklineBaseUrl = options.mklineBaseUrl ?? DEFAULT_MKLINE_BASE_URL;
458
465
  this.#searchBaseUrl = options.searchBaseUrl ?? DEFAULT_SEARCH_BASE_URL;
459
466
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -463,22 +470,69 @@ var TencentRestClient = class {
463
470
  get market() {
464
471
  return this.#market;
465
472
  }
466
- async #requestArrayBuffer(url) {
473
+ /** 单请求带超时闸门;网络级失败/超时统一抛 TRADING_NETWORK。 */
474
+ async #fetchWithTimeout(url) {
467
475
  const controller = new AbortController();
468
476
  const timer = setTimeout(() => controller.abort(new DOMException(`request timed out after ${this.#timeoutMs}ms`, "TimeoutError")), this.#timeoutMs);
469
- let res;
470
477
  try {
471
- res = await this.#fetchImpl(url, { signal: controller.signal });
478
+ return await this.#fetchImpl(url, { signal: controller.signal });
472
479
  } catch (cause) {
473
480
  const timedOut = controller.signal.aborted;
474
481
  throw new TradingServiceError("TRADING_NETWORK", timedOut ? `Tencent ${url}: request timed out after ${this.#timeoutMs}ms` : `Tencent ${url}: network error`, cause);
475
482
  } finally {
476
483
  clearTimeout(timer);
477
484
  }
485
+ }
486
+ async #requestArrayBuffer(url) {
487
+ const res = await this.#fetchWithTimeout(url);
478
488
  if (!res.ok) throw new TradingServiceError(res.status === 429 ? "TRADING_RATE_LIMITED" : "TRADING_EXCHANGE_ERROR", `Tencent ${url}: HTTP ${res.status} ${res.statusText}`);
479
489
  return new Uint8Array(await res.arrayBuffer());
480
490
  }
481
491
  /**
492
+ * 单域名取一份 K 线 JSON,把「能不能换下一个域名」的判断就地做掉:
493
+ * 网络级失败(含超时)、HTTP 5xx、非 JSON(WAF 挑战页/HTML 错误页)可换;429/4xx 与
494
+ * 其余业务语义(code!=0、无行、坏行)不重试、由调用方原样 fail-closed。
495
+ */
496
+ async #requestKlinePayload(url, sym) {
497
+ let res;
498
+ try {
499
+ res = await this.#fetchWithTimeout(url);
500
+ } catch (cause) {
501
+ return {
502
+ ok: false,
503
+ retryable: true,
504
+ error: cause instanceof TradingServiceError ? cause : new TradingServiceError("TRADING_NETWORK", `Tencent ${url}: network error`, cause)
505
+ };
506
+ }
507
+ if (!res.ok) return {
508
+ ok: false,
509
+ retryable: res.status >= 500,
510
+ error: new TradingServiceError(res.status === 429 ? "TRADING_RATE_LIMITED" : "TRADING_EXCHANGE_ERROR", `Tencent ${url}: HTTP ${res.status} ${res.statusText}`)
511
+ };
512
+ let bytes;
513
+ try {
514
+ bytes = new Uint8Array(await res.arrayBuffer());
515
+ } catch (cause) {
516
+ return {
517
+ ok: false,
518
+ retryable: true,
519
+ error: new TradingServiceError("TRADING_NETWORK", `Tencent ${url}: network error`, cause)
520
+ };
521
+ }
522
+ try {
523
+ return {
524
+ ok: true,
525
+ payload: JSON.parse(new TextDecoder("utf-8").decode(bytes))
526
+ };
527
+ } catch (cause) {
528
+ return {
529
+ ok: false,
530
+ retryable: true,
531
+ error: new TradingServiceError("TRADING_EXCHANGE_ERROR", `Tencent klines for ${sym}: non-JSON payload`, cause)
532
+ };
533
+ }
534
+ }
535
+ /**
482
536
  * 报价 wire 形态:cn=sh600519;hk=r_hk00700(hk K 线另用 hk 前缀,见文件头注)。
483
537
  * **大小写原样进 URL**:hk 指数 wire 大小写敏感(`r_hkHSI` 有行、`r_hkhsi` 返回
484
538
  * `v_pv_none_match="1";`,2026-10-14 实测),只有响应查表键才小写归一。
@@ -644,13 +698,24 @@ var TencentRestClient = class {
644
698
  const count = typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? Math.min(limit, 800) : 100;
645
699
  const wire = this.#klineWireCode(sym);
646
700
  const isMinute = mapping.type === "mkline";
647
- const url = isMinute ? `${this.#mklineBaseUrl}/appstock/app/kline/mkline?param=${wire},${mapping.tf},,${count}` : `${this.#klineBaseUrl}/${this.#market === "hk" ? "appstock/app/hkfqkline/get" : "appstock/app/fqkline/get"}?param=${wire},${mapping.tf},,,${count},qfq`;
648
- const bytes = await this.#requestArrayBuffer(url);
701
+ const klinePath = this.#market === "hk" ? "appstock/app/hkfqkline/get" : "appstock/app/fqkline/get";
702
+ const urlFor = (base) => isMinute ? `${base}/appstock/app/kline/mkline?param=${wire},${mapping.tf},,${count}` : `${base}/${klinePath}?param=${wire},${mapping.tf},,,${count},qfq`;
703
+ const bases = isMinute ? [this.#mklineBaseUrl] : this.#klineBaseUrls;
649
704
  let payload;
650
- try {
651
- payload = JSON.parse(new TextDecoder("utf-8").decode(bytes));
652
- } catch (cause) {
653
- throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Tencent klines for ${sym}: non-JSON payload`, cause);
705
+ let lastError;
706
+ for (const base of bases) {
707
+ const attempt = await this.#requestKlinePayload(urlFor(base), sym);
708
+ if (attempt.ok) {
709
+ payload = attempt.payload;
710
+ break;
711
+ }
712
+ lastError = attempt.error;
713
+ if (!attempt.retryable) throw attempt.error;
714
+ }
715
+ if (payload === void 0) {
716
+ const error = lastError ?? new TradingServiceError("TRADING_NETWORK", `Tencent klines for ${sym}: no kline host configured`);
717
+ if (bases.length === 1) throw error;
718
+ throw new TradingServiceError(error.code, `Tencent klines for ${sym}: all kline hosts failed (${bases.join(" -> ")}); last: ${error.message}`, error);
654
719
  }
655
720
  if (payload.code !== 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Tencent klines for ${sym}: upstream code=${String(payload.code)} msg=${JSON.stringify(payload.msg ?? "")}`);
656
721
  const market = payload.data?.[wire];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/connector-tencent",
3
3
  "description": "CN + HK dual-market connector plugin for dsh-trading: public market data from Tencent quote/kline endpoints implementing the @dshtrading/api contracts (single package, per-instance market split; no key, no official authorization — personal use boundary at your own discretion)",
4
- "version": "0.6.3",
4
+ "version": "0.7.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -21,13 +21,13 @@
21
21
  ],
22
22
  "license": "PolyForm-Noncommercial-1.0.0",
23
23
  "dependencies": {
24
- "@dshtrading/authority": "^0.6.3",
25
- "@dshtrading/api": "^0.6.3"
24
+ "@dshtrading/authority": "^0.7.1",
25
+ "@dshtrading/api": "^0.7.1"
26
26
  },
27
27
  "peerDependencies": {
28
- "@deepseek-ai/cordis": ">=4.0.0",
29
- "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2",
30
- "@deepseek-ai/schemastery": ">=3.18.0"
28
+ "@deepseek-ai/cordis": ">=4.1.0",
29
+ "@deepseek-ai/dsh-tools": ">=0.2.2-rc.1",
30
+ "@deepseek-ai/schemastery": ">=3.19.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "tsdown": "^0.22.0",