gangtise-openapi-cli 0.21.0 → 0.22.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.
@@ -7,8 +7,7 @@ import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredent
7
7
  import { ApiError, ValidationError } from "./errors.js";
8
8
  import { ENDPOINTS } from "./endpoints.js";
9
9
  import { getLookupData } from "./lookupData/index.js";
10
- import { getDispatcher, isVerbose, logTiming, markRetryable, runWithConcurrency, withRetry } from "./transport.js";
11
- const PAGINATION_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
10
+ import { getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, runWithConcurrency, withRetry } from "./transport.js";
12
11
  // Auth errors that warrant a forced re-login + one replay. 8000014/8000015 are
13
12
  // AK/SK errors; 0000001008 is a server-side token invalidation (the token still
14
13
  // looks valid by local expiry, so only a forced refresh recovers it).
@@ -48,11 +47,28 @@ export class GangtiseClient {
48
47
  accessKey: credentials.accessKey,
49
48
  secretKey: credentials.secretKey,
50
49
  }, false);
50
+ // Validate the shape before touching it: a missing accessToken used to surface
51
+ // as a bare TypeError from normalizeToken, hiding the real cause.
52
+ if (typeof envelope?.accessToken !== "string" || !envelope.accessToken) {
53
+ throw new ApiError("Login succeeded but the response carried no accessToken", undefined, undefined, envelope);
54
+ }
51
55
  const accessToken = normalizeToken(envelope.accessToken);
52
- const expiresAt = Math.floor(Date.now() / 1000) + envelope.expiresIn;
53
- const cache = { ...envelope, accessToken, expiresAt };
56
+ // A non-numeric expiresIn would make expiresAt NaN the cache would never
57
+ // validate and every command would silently re-login. Degrade to 0 instead
58
+ // (token works now, next process logs in again).
59
+ const expiresIn = Number.isFinite(envelope.expiresIn) ? envelope.expiresIn : 0;
60
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
61
+ const cache = { ...envelope, accessToken, expiresIn, expiresAt };
54
62
  this.memoCache = cache;
55
- await writeTokenCache(this.config.tokenCachePath, cache);
63
+ try {
64
+ await writeTokenCache(this.config.tokenCachePath, cache);
65
+ }
66
+ catch (error) {
67
+ // A read-only HOME or full disk must not fail the request — the in-memory
68
+ // token is valid; we just can't persist it for the next process.
69
+ const msg = error instanceof Error ? error.message : String(error);
70
+ process.stderr.write(`[gangtise] warning: could not persist token cache: ${msg}\n`);
71
+ }
56
72
  return accessToken;
57
73
  }
58
74
  /**
@@ -62,7 +78,7 @@ export class GangtiseClient {
62
78
  * the caller re-throws the original error. `authState` persists across the
63
79
  * withRetry attempts so we only refresh once per logical request.
64
80
  */
65
- async refreshAuthIfRecoverable(error, useAuth, authState) {
81
+ async refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization) {
66
82
  if (useAuth
67
83
  && !authState.retried
68
84
  && error instanceof ApiError
@@ -71,12 +87,30 @@ export class GangtiseClient {
71
87
  && this.config.accessKey
72
88
  && this.config.secretKey) {
73
89
  authState.retried = true;
74
- this.memoCache = null;
75
90
  this.envTokenInvalidated = true;
76
- await this.getAuthorizationHeader(true);
91
+ // If the failed request was still carrying an OLDER token than the one now in
92
+ // memoCache, another request already refreshed — replay with the fresh token
93
+ // instead of logging in again (back-to-back logins can kick each other's
94
+ // sessions server-side, the 0000001008 semantics). If the failed request used
95
+ // the CURRENT token, that token is genuinely dead: force a new login. A time
96
+ // window is NOT a valid proxy here — right after the initial login the window
97
+ // is always "recent", which would skip the refresh exactly when it's needed.
98
+ const memoToken = this.memoCache && isTokenCacheValid(this.memoCache) ? normalizeToken(this.memoCache.accessToken) : null;
99
+ const alreadyRefreshed = memoToken !== null && usedAuthorization !== undefined && usedAuthorization !== memoToken;
100
+ if (!alreadyRefreshed) {
101
+ this.memoCache = null;
102
+ await this.getAuthorizationHeader(true);
103
+ }
77
104
  throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
78
105
  }
79
106
  }
107
+ /** `new URL("/a/b", "https://proxy/prefix")` drops "/prefix" — an absolute path
108
+ * replaces the base's path per the URL spec. Join manually so a reverse-proxy
109
+ * GANGTISE_BASE_URL with a path prefix keeps working. */
110
+ buildUrl(path) {
111
+ const base = this.config.baseUrl.endsWith("/") ? this.config.baseUrl : `${this.config.baseUrl}/`;
112
+ return new URL(path.replace(/^\//, ""), base);
113
+ }
80
114
  isEnvelope(parsed) {
81
115
  if (!parsed || typeof parsed !== 'object')
82
116
  return false;
@@ -145,16 +179,25 @@ export class GangtiseClient {
145
179
  return firstPage;
146
180
  const total = firstPage.total;
147
181
  const collected = [...firstPage.list];
148
- // Last page reached on first request
182
+ const available = Math.max(total - startFrom, 0);
183
+ const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
184
+ // Last page reached on first request. If `total` promises more rows than the
185
+ // short page delivered, the server's page cap may be lower than our configured
186
+ // maxPageSize — say so instead of silently returning a subset as "everything".
149
187
  if (firstPage.list.length < firstPageSize) {
150
- return {
188
+ const out = {
151
189
  ...firstPage,
152
190
  total,
153
191
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
154
192
  };
193
+ if (collected.length < target) {
194
+ process.stderr.write(`[gangtise] warning: server returned a short page (${collected.length} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
195
+ // Machine-readable counterpart of the warning: scripts key off partial /
196
+ // exit code 3, and must not mistake a truncated result for a complete one.
197
+ out.partial = true;
198
+ }
199
+ return out;
155
200
  }
156
- const available = Math.max(total - startFrom, 0);
157
- const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
158
201
  if (collected.length >= target) {
159
202
  return {
160
203
  ...firstPage,
@@ -162,21 +205,24 @@ export class GangtiseClient {
162
205
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
163
206
  };
164
207
  }
208
+ // Build remaining page requests. The cap lives inside the loop: a corrupt
209
+ // server `total` (e.g. 9e15) must not materialize millions of page objects —
210
+ // or spin for minutes — before a post-hoc truncation applies.
211
+ const MAX_PAGES = 1000;
212
+ let truncatedByPageCap = false;
165
213
  const pageRequests = [];
166
214
  let nextFrom = startFrom + firstPage.list.length;
167
215
  const endFrom = startFrom + target;
168
216
  while (nextFrom < endFrom) {
217
+ if (pageRequests.length + 1 >= MAX_PAGES) {
218
+ truncatedByPageCap = true;
219
+ break;
220
+ }
169
221
  const remaining = endFrom - nextFrom;
170
222
  const size = Math.min(maxPageSize, remaining);
171
223
  pageRequests.push({ from: nextFrom, size });
172
224
  nextFrom += size;
173
225
  }
174
- const MAX_PAGES = 1000;
175
- let truncatedByPageCap = false;
176
- if (pageRequests.length + 1 > MAX_PAGES) {
177
- pageRequests.length = MAX_PAGES - 1;
178
- truncatedByPageCap = true;
179
- }
180
226
  let unexpectedShape = false;
181
227
  let totalDrift = false;
182
228
  // Fail-soft fan-out: a hard page failure (rate-limit 903301, no-perm, retries
@@ -187,7 +233,7 @@ export class GangtiseClient {
187
233
  const failedPages = [];
188
234
  let firstError = null;
189
235
  let aborted = false;
190
- const pages = await runWithConcurrency(pageRequests, PAGINATION_CONCURRENCY, async (req) => {
236
+ const pages = await runWithConcurrency(pageRequests, PAGE_CONCURRENCY, async (req) => {
191
237
  if (aborted) {
192
238
  failedPages.push(req);
193
239
  return [];
@@ -199,7 +245,10 @@ export class GangtiseClient {
199
245
  size: req.size,
200
246
  });
201
247
  if (!this.isPaginatedListResponse(page)) {
248
+ // Treat a shape-broken page like a failed page: its rows are missing, so
249
+ // the result must carry the partial marker instead of looking complete.
202
250
  unexpectedShape = true;
251
+ failedPages.push(req);
203
252
  return [];
204
253
  }
205
254
  if (page.total !== total)
@@ -219,11 +268,11 @@ export class GangtiseClient {
219
268
  continue;
220
269
  collected.push(...list);
221
270
  }
222
- if (unexpectedShape && isVerbose()) {
223
- process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results may be incomplete\n`);
271
+ if (unexpectedShape) {
272
+ process.stderr.write(`[gangtise] warning: a page response had unexpected shape; its rows are missing (counted in failedPages)\n`);
224
273
  }
225
- if (totalDrift && isVerbose()) {
226
- process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch)\n`);
274
+ if (totalDrift) {
275
+ process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch); rows may be duplicated or missing\n`);
227
276
  }
228
277
  // Always surface a cap-induced truncation (not gated on verbose): the user
229
278
  // asked for everything and is silently getting a subset, mirroring the
@@ -240,7 +289,8 @@ export class GangtiseClient {
240
289
  out.partial = true;
241
290
  out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
242
291
  const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
243
- process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages). A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped.\n`);
292
+ const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
293
+ process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages).${skippedHint}\n`);
244
294
  }
245
295
  return out;
246
296
  }
@@ -274,6 +324,7 @@ export class GangtiseClient {
274
324
  let from = startFrom;
275
325
  const MAX_PAGES = 1000;
276
326
  let truncatedByPageCap = false;
327
+ let partialShape = false;
277
328
  for (let page = 0;; page++) {
278
329
  const remaining = requestedSize === undefined ? maxPageSize : requestedSize - collected.length;
279
330
  if (requestedSize !== undefined && remaining <= 0)
@@ -289,6 +340,7 @@ export class GangtiseClient {
289
340
  // requestPaginated's fail-soft fan-out): stop, keep them, and warn loudly.
290
341
  if (page === 0)
291
342
  return firstPage;
343
+ partialShape = true;
292
344
  process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results are partial — ${collected.length} rows fetched.\n`);
293
345
  break;
294
346
  }
@@ -306,7 +358,10 @@ export class GangtiseClient {
306
358
  process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} rows. Pass --size to fetch a bounded subset.\n`);
307
359
  }
308
360
  const rows = requestedSize === undefined ? collected : collected.slice(0, requestedSize);
309
- return { [listKey]: rows };
361
+ const out = { [listKey]: rows };
362
+ if (partialShape)
363
+ out.partial = true;
364
+ return out;
310
365
  }
311
366
  async login() {
312
367
  const authorization = await this.getAuthorizationHeader();
@@ -321,14 +376,18 @@ export class GangtiseClient {
321
376
  return this.readLocalLookup(endpoint);
322
377
  }
323
378
  const dispatcher = getDispatcher();
324
- const url = new URL(endpoint.path, this.config.baseUrl);
379
+ const url = this.buildUrl(endpoint.path);
325
380
  const authState = { retried: false };
326
381
  return withRetry(async () => {
327
382
  const headers = {
328
383
  'content-type': 'application/json',
329
384
  };
385
+ // Keep the header we actually sent: the self-heal check compares it against
386
+ // the current memoCache token to tell "stale token" from "fresh token died".
387
+ let usedAuthorization;
330
388
  if (useAuth) {
331
- headers.Authorization = await this.getAuthorizationHeader();
389
+ usedAuthorization = await this.getAuthorizationHeader();
390
+ headers.Authorization = usedAuthorization;
332
391
  }
333
392
  const startedAt = Date.now();
334
393
  const response = await request(url, {
@@ -351,14 +410,16 @@ export class GangtiseClient {
351
410
  : 'Failed to parse API response';
352
411
  throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
353
412
  }
354
- if (response.statusCode >= 400) {
355
- this.throwHttpError(parsed, response.statusCode);
356
- }
357
413
  try {
414
+ // Auth errors can arrive as HTTP 4xx or as a 200-wrapped error envelope;
415
+ // both routes must reach the self-heal check below.
416
+ if (response.statusCode >= 400) {
417
+ this.throwHttpError(parsed, response.statusCode);
418
+ }
358
419
  return this.unwrapEnvelope(parsed, response.statusCode);
359
420
  }
360
421
  catch (error) {
361
- await this.refreshAuthIfRecoverable(error, useAuth, authState);
422
+ await this.refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization);
362
423
  throw error;
363
424
  }
364
425
  }, {
@@ -372,7 +433,7 @@ export class GangtiseClient {
372
433
  }
373
434
  async download(endpoint, query, options) {
374
435
  const dispatcher = getDispatcher();
375
- const url = new URL(endpoint.path, this.config.baseUrl);
436
+ const url = this.buildUrl(endpoint.path);
376
437
  Object.entries(query).forEach(([key, value]) => {
377
438
  url.searchParams.set(key, String(value));
378
439
  });
@@ -380,13 +441,37 @@ export class GangtiseClient {
380
441
  return withRetry(async () => {
381
442
  const authorization = await this.getAuthorizationHeader();
382
443
  const startedAt = Date.now();
383
- const response = await request(url, {
444
+ let currentUrl = url;
445
+ let auth = authorization;
446
+ let response = await request(currentUrl, {
384
447
  method: endpoint.method,
385
448
  headers: { Authorization: authorization },
386
449
  headersTimeout: this.config.timeoutMs,
387
450
  bodyTimeout: this.config.timeoutMs,
388
451
  dispatcher,
389
452
  });
453
+ // undici does not follow redirects, and a download endpoint may 302 to a
454
+ // pre-signed object-store URL — without this the redirect body would be
455
+ // saved as the "file". Follow up to 3 hops, dropping Authorization once the
456
+ // redirect leaves the API origin so the bearer never reaches storage hosts.
457
+ for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
458
+ const locationHeader = response.headers.location;
459
+ const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
460
+ if (!location)
461
+ break;
462
+ await response.body.text().catch(() => { });
463
+ const next = new URL(location, currentUrl);
464
+ if (next.origin !== currentUrl.origin)
465
+ auth = undefined;
466
+ currentUrl = next;
467
+ response = await request(currentUrl, {
468
+ method: 'GET',
469
+ headers: auth ? { Authorization: auth } : {},
470
+ headersTimeout: this.config.timeoutMs,
471
+ bodyTimeout: this.config.timeoutMs,
472
+ dispatcher,
473
+ });
474
+ }
390
475
  const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
391
476
  if (contentType?.includes('application/json')) {
392
477
  const text = await response.body.text();
@@ -401,15 +486,15 @@ export class GangtiseClient {
401
486
  }
402
487
  return { text, contentType };
403
488
  }
404
- if (response.statusCode >= 400) {
405
- this.throwHttpError(parsed, response.statusCode);
406
- }
407
489
  let data;
408
490
  try {
491
+ if (response.statusCode >= 400) {
492
+ this.throwHttpError(parsed, response.statusCode);
493
+ }
409
494
  data = this.unwrapEnvelope(parsed, response.statusCode);
410
495
  }
411
496
  catch (error) {
412
- await this.refreshAuthIfRecoverable(error, true, authState);
497
+ await this.refreshAuthIfRecoverable(error, true, authState, authorization);
413
498
  throw error;
414
499
  }
415
500
  if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') {
@@ -433,7 +518,19 @@ export class GangtiseClient {
433
518
  const filenameMatch = Array.isArray(contentDisposition)
434
519
  ? contentDisposition[0]?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
435
520
  : contentDisposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
436
- const filename = filenameMatch ? decodeURIComponent(filenameMatch[1] || filenameMatch[2]) : undefined;
521
+ // A plain filename= value with a bare % ("增长100%.pdf") is not valid URI
522
+ // encoding — decodeURIComponent would throw and fail the whole download over
523
+ // a cosmetic hint. Fall back to the raw value instead.
524
+ let filename;
525
+ if (filenameMatch) {
526
+ const raw = filenameMatch[1] || filenameMatch[2];
527
+ try {
528
+ filename = decodeURIComponent(raw);
529
+ }
530
+ catch {
531
+ filename = raw;
532
+ }
533
+ }
437
534
  // Stream directly to disk when caller already knows the destination
438
535
  if (options?.streamTo) {
439
536
  await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs/promises";
1
2
  import { extname } from "node:path";
2
3
  import { DownloadError } from "./errors.js";
3
4
  import { saveOutputIfNeeded } from "./output.js";
@@ -9,6 +10,36 @@ import { lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE } from "./titleCach
9
10
  function sanitizeFilename(name) {
10
11
  return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
11
12
  }
13
+ /** Keep auto-derived filenames under 200 UTF-8 bytes, preserving the extension.
14
+ * ext4 caps directory entries at 255 bytes — long Chinese titles hit that at
15
+ * ~85 chars (3 bytes each) and fs.writeFile throws ENAMETOOLONG after the body
16
+ * has already been downloaded. */
17
+ function truncateFilename(name, maxBytes = 200) {
18
+ if (Buffer.byteLength(name, "utf8") <= maxBytes)
19
+ return name;
20
+ const ext = extname(name);
21
+ let stem = name.slice(0, name.length - ext.length);
22
+ while (stem.length > 1 && Buffer.byteLength(stem + ext, "utf8") > maxBytes)
23
+ stem = stem.slice(0, -1);
24
+ return stem + ext;
25
+ }
26
+ /** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
27
+ * downloads whose titles collide ("2025年第一季度报告" from several companies) don't
28
+ * silently overwrite each other. Only auto-derived names go through this — an
29
+ * explicit --output path keeps plain overwrite semantics. */
30
+ export async function uniquePath(p) {
31
+ const exists = (f) => fs.access(f).then(() => true, () => false);
32
+ if (!(await exists(p)))
33
+ return p;
34
+ const ext = extname(p);
35
+ const stem = p.slice(0, p.length - ext.length);
36
+ for (let i = 1; i <= 99; i++) {
37
+ const candidate = `${stem}-${i}${ext}`;
38
+ if (!(await exists(candidate)))
39
+ return candidate;
40
+ }
41
+ return p;
42
+ }
12
43
  const MIME_EXT = {
13
44
  "application/pdf": ".pdf",
14
45
  "application/msword": ".doc",
@@ -49,7 +80,7 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
49
80
  if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
50
81
  title += serverExt;
51
82
  }
52
- return title;
83
+ return truncateFilename(title);
53
84
  }
54
85
  try {
55
86
  const cacheData = await readTitleCache();
@@ -73,11 +104,32 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
73
104
  }
74
105
  return undefined;
75
106
  }
107
+ async function downloadUrlTo(url, outputPath) {
108
+ const { createWriteStream } = await import("node:fs");
109
+ const { Readable } = await import("node:stream");
110
+ const { pipeline } = await import("node:stream/promises");
111
+ const { dirname } = await import("node:path");
112
+ const response = await fetch(url);
113
+ if (!response.ok || !response.body) {
114
+ throw new DownloadError(`Failed to fetch download URL (HTTP ${response.status})`);
115
+ }
116
+ await fs.mkdir(dirname(outputPath), { recursive: true });
117
+ try {
118
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(outputPath));
119
+ }
120
+ catch (error) {
121
+ await fs.unlink(outputPath).catch(() => { });
122
+ throw error;
123
+ }
124
+ }
76
125
  export async function saveDownloadResult(result, fallbackName, output) {
77
126
  if (!(result && typeof result === "object")) {
78
127
  throw new DownloadError("Unexpected download response");
79
128
  }
80
129
  const file = result;
130
+ // The fallback prefix embeds a user-supplied id (e.g. --report-id): sanitize it
131
+ // like server-provided names so an auto filename never interprets input as a path.
132
+ const safeFallback = sanitizeFilename(fallbackName);
81
133
  if (typeof file.savedPath === "string") {
82
134
  process.stdout.write(`${file.savedPath}\n`);
83
135
  return;
@@ -85,20 +137,24 @@ export async function saveDownloadResult(result, fallbackName, output) {
85
137
  if (file.data instanceof Uint8Array) {
86
138
  // Sanitize the server-provided filename so a Content-Disposition value with
87
139
  // / or : can't write outside the intended path (same rule as buildFilename).
88
- const outputPath = output ?? (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (fallbackName + extFromContentType(file.contentType));
140
+ const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
141
+ const outputPath = output ?? await uniquePath(truncateFilename(autoName));
89
142
  await saveOutputIfNeeded(file.data, outputPath);
90
143
  process.stdout.write(`${outputPath}\n`);
91
144
  return;
92
145
  }
93
146
  if (typeof file.text === "string") {
94
- const outputPath = output ?? `${fallbackName}.txt`;
147
+ const outputPath = output ?? await uniquePath(truncateFilename(`${safeFallback}.txt`));
95
148
  await saveOutputIfNeeded(file.text, outputPath);
96
149
  process.stdout.write(`${outputPath}\n`);
97
150
  return;
98
151
  }
99
152
  if (typeof file.url === "string") {
100
153
  if (output) {
101
- await saveOutputIfNeeded(file.url, output);
154
+ // The server handed us a (typically signed, short-lived) URL instead of the
155
+ // bytes. The user asked for a file — follow the URL and stream the content
156
+ // to disk instead of writing the URL string into a fake .pdf.
157
+ await downloadUrlTo(file.url, output);
102
158
  process.stdout.write(`${output}\n`);
103
159
  return;
104
160
  }