vanseleem commited on
Commit
01e9f7d
·
verified ·
1 Parent(s): e0b18cd

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +171 -39
index.js CHANGED
@@ -4,6 +4,7 @@ const cheerio = require('cheerio');
4
  const AdmZip = require('adm-zip');
5
  const https = require('https');
6
  const http = require('http');
 
7
  const zlib = require('zlib');
8
  const { URL } = require('url');
9
 
@@ -21,9 +22,8 @@ app.use((req, res, next) => {
21
  // ================================================================
22
  // PROXY CONFIG ← edit these three lines to swap proxies
23
  // Each takes a URL-ENCODED target appended to it. Set to '' to disable a slot.
24
- // Dropped corsproxy.io (now requires an API key for programmatic use) and
25
- // thingproxy.freeboard.io (dead). These two are the ones already proven in
26
- // your SubDL Workers addon.
27
  // ================================================================
28
  const PROXY_1 = 'https://api.codetabs.com/v1/proxy?quest=';
29
  const PROXY_2 = 'https://api.allorigins.win/raw?url=';
@@ -33,9 +33,12 @@ const PROXY_3 = '';
33
  // ================================================================
34
  // DOWNLOAD TUNING ← edit these to change retry/pacing behaviour
35
  // ================================================================
36
- const DL_RETRY_DELAY = 3000; // ms between download attempts (404/503 backoff)
37
  const DL_STEP_DELAY = 600; // ms between detail-page fetch and download fetch
38
- const DL_ATTEMPTS = 3; // direct attempts before falling back to proxies
 
 
 
39
  const DEBUG_ROUTE = true; // expose GET /debug/:enc — set false to disable
40
  // ================================================================
41
 
@@ -167,7 +170,7 @@ const diag = {
167
  extractFailed: 0, // got a body back but couldn't pull a subtitle out of it
168
  downloadsOK: 0,
169
  lastFailStage: null, // 'detail' | 'button' | 'fetch' | 'extract'
170
- lastStatuses: [], // real HTTP status codes from the last download attempt
171
  lastArchiveKind: null, // 'zip' | 'rar' | '7z' | 'gzip' | 'html' | 'unknown'
172
  lastNote: null, // human-readable reason the last attempt died
173
  };
@@ -206,9 +209,10 @@ function _cookiesFrom(res, prior) {
206
  // identical from the outside.
207
  function _request(urlStr, opts = {}, redirects = 0, jar = '') {
208
  return new Promise((resolve, reject) => {
209
- if (!urlStr || redirects > MAX_REDIRECTS) return reject(new Error('Invalid redirect chain'));
210
  let parsed;
211
  try { parsed = new URL(urlStr); } catch (_) { return reject(new Error('Invalid URL')); }
 
212
  const lib = parsed.protocol === 'https:' ? https : http;
213
  const timeout = opts.timeout || REQ_TIMEOUT;
214
  const maxBytes = opts.maxBytes || MAX_BODY_BYTES;
@@ -219,7 +223,9 @@ function _request(urlStr, opts = {}, redirects = 0, jar = '') {
219
  // 'application/zip,...' value on binary fetches was a fingerprintable tell.
220
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
221
  'Accept-Language': opts.lang === 'arabic' ? 'ar,en-US;q=0.8,en;q=0.6' : 'en-US,en;q=0.9',
222
- 'Accept-Encoding': 'gzip, deflate, br',
 
 
223
  'Referer': `${BASE}/`,
224
  'Connection': 'keep-alive',
225
  'Cache-Control': 'no-cache',
@@ -234,6 +240,7 @@ function _request(urlStr, opts = {}, redirects = 0, jar = '') {
234
  method: 'GET',
235
  headers,
236
  };
 
237
  const req = lib.request(reqOpts, res => {
238
  const status = res.statusCode;
239
  const carried = _cookiesFrom(res, cookie);
@@ -275,6 +282,81 @@ function _request(urlStr, opts = {}, redirects = 0, jar = '') {
275
  });
276
  }
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  // Returns { body, status, cookies }. body is null when the fetch didn't yield a 200.
279
  async function getHTMLEx(url, tries = 3, lang = 'english', cookies = '') {
280
  let status = 0, jar = cookies;
@@ -295,42 +377,91 @@ async function getHTML(url, tries = 3, lang = 'english') {
295
  return (await getHTMLEx(url, tries, lang)).body;
296
  }
297
 
298
- async function getBinary(url, referer, cookies, statuses) {
299
- // Two things matter here and both were missing before:
300
- // 1. Referer must be the subtitle DETAIL page. A browser arrives at /download
301
- // from there, never from the site root.
302
- // 2. subf2m returns transient 404/503 on /download often enough that the
303
- // reference implementation retries those specifically with a 3s sleep.
304
- // The old code retried 3x back-to-back with NO delay, which cannot clear a
305
- // throttle and looks like a bot hammering the endpoint.
306
- const headers = {
 
 
 
 
307
  'Sec-Fetch-Dest': 'document',
308
  'Sec-Fetch-Mode': 'navigate',
309
  'Sec-Fetch-Site': 'same-origin',
310
  'Sec-Fetch-User': '?1',
311
  };
312
- if (referer) headers.Referer = referer;
 
 
 
 
 
 
 
 
 
313
  for (let i = 0; i < DL_ATTEMPTS; i++) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  try {
315
- const r = await _request(url, { binary: true, timeout: DL_TIMEOUT, maxBytes: MAX_SRT_BYTES, headers, cookies });
316
- if (Array.isArray(statuses)) statuses.push(r.status);
317
- if (r.body !== null && r.body.length > 0) return r.body;
 
318
  // Only 404/503 are worth waiting out; anything else won't change on retry.
319
- if (r.status !== 404 && r.status !== 503) break;
320
  } catch (e) {
321
- if (Array.isArray(statuses)) statuses.push(-1);
322
  }
323
- if (i < DL_ATTEMPTS - 1) await sleep(DL_RETRY_DELAY);
324
  }
 
325
  const enc = encodeURIComponent(url);
326
  for (const base of [PROXY_1, PROXY_2, PROXY_3]) {
327
  if (!base) continue;
328
  try {
329
  const r = await _request(`${base}${enc}`, { binary: true, timeout: DL_TIMEOUT, maxBytes: MAX_SRT_BYTES });
330
- if (Array.isArray(statuses)) statuses.push(`proxy:${r.status}`);
331
- if (r.body && r.body.length > 50) return r.body;
332
- } catch (_) {
333
- if (Array.isArray(statuses)) statuses.push('proxy:err');
334
  }
335
  }
336
  return null;
@@ -965,8 +1096,8 @@ function archiveKind(buf) {
965
  // ================================================================
966
  // Walks the same path a browser does: load the subtitle detail page, read the
967
  // real href off its download button, then request that URL carrying the detail
968
- // page as Referer plus any session cookie the page handed us. Every stage
969
- // records what happened into `trace` so /debug can show exactly where it broke.
970
  async function downloadSubtitle(pageUrl, trace = {}) {
971
  trace.pageUrl = pageUrl;
972
 
@@ -1004,13 +1135,14 @@ async function downloadSubtitle(pageUrl, trace = {}) {
1004
 
1005
  // Stage 3 — fetch the archive, pacing the request like a real click
1006
  await sleep(DL_STEP_DELAY);
1007
- const statuses = [];
1008
- const buf = await getBinary(downloadUrl, pageUrl, detail.cookies, statuses);
1009
- trace.downloadStatuses = statuses;
1010
- diag.lastStatuses = statuses;
1011
  if (!buf) {
1012
  diag.downloadFetchFailed++;
1013
- noteFail('fetch', `download returned ${statuses.join(', ') || 'nothing'}`);
 
1014
  trace.failedAt = 'fetch';
1015
  return null;
1016
  }
@@ -1091,7 +1223,7 @@ app.get('/manifest.json', (req, res) => {
1091
  id: 'org.van.vansubf2mplus.steel',
1092
  name: 'VanSubF2M Steel',
1093
  description: 'Hardened Subf2m English & Arabic subtitles with release-aware matching and anti-AI filtering.',
1094
- version: '3.2.0',
1095
  resources: ['subtitles'],
1096
  types: ['movie', 'series'],
1097
  idPrefixes: ['tt'],
@@ -1106,7 +1238,7 @@ app.get('/sub/:enc.srt', async (req, res) => {
1106
  let pageUrl = '';
1107
  try { pageUrl = Buffer.from(req.params.enc, 'base64url').toString('utf8'); }
1108
  catch (_) { return res.status(400).end('Bad request'); }
1109
- // This now carries the subtitle DETAIL page URL (not a pre-built /download URL) —
1110
  // the real download link is resolved fresh from that page in fetchSRT.
1111
  if (!/^https:\/\/subf2m\.co\/subtitles\/[^\s\/]+\/(?:english|arabic)\/\d+\/?$/i.test(pageUrl)) return res.status(403).end('Forbidden');
1112
  const content = await fetchSRT(pageUrl);
@@ -1133,7 +1265,7 @@ if (DEBUG_ROUTE) {
1133
  const trace = {};
1134
  let content = null;
1135
  try { content = await downloadSubtitle(pageUrl, trace); }
1136
- catch (e) { trace.threw = e?.message || String(e); }
1137
  res.json({ ok: !!content, trace, preview: content ? content.slice(0, 300) : null });
1138
  });
1139
  }
@@ -1225,7 +1357,7 @@ app.get('/health', (req, res) => {
1225
  res.json({
1226
  ok: true,
1227
  service: 'VanSubF2M Steel',
1228
- version: '3.2.0',
1229
  debugRoute: DEBUG_ROUTE ? '/debug/<same base64url as /sub/>' : 'disabled',
1230
  downloadDiagnostics: diag,
1231
  });
 
4
  const AdmZip = require('adm-zip');
5
  const https = require('https');
6
  const http = require('http');
7
+ const http2 = require('http2');
8
  const zlib = require('zlib');
9
  const { URL } = require('url');
10
 
 
22
  // ================================================================
23
  // PROXY CONFIG ← edit these three lines to swap proxies
24
  // Each takes a URL-ENCODED target appended to it. Set to '' to disable a slot.
25
+ // PROXY_3 is a good slot for your own Cloudflare Worker relay — Worker egress
26
+ // already reaches CF-fronted subtitle sites in your SubDL addon.
 
27
  // ================================================================
28
  const PROXY_1 = 'https://api.codetabs.com/v1/proxy?quest=';
29
  const PROXY_2 = 'https://api.allorigins.win/raw?url=';
 
33
  // ================================================================
34
  // DOWNLOAD TUNING ← edit these to change retry/pacing behaviour
35
  // ================================================================
36
+ const DL_RETRY_DELAY = 2000; // ms between download attempts (404/503 backoff)
37
  const DL_STEP_DELAY = 600; // ms between detail-page fetch and download fetch
38
+ const DL_ATTEMPTS = 2; // attempts per transport before moving to the next
39
+ const DL_HARD_TIMEOUT = 12000;// ms per download attempt — fail fast, don't hang
40
+ const TRY_HTTP2 = true; // attempt HTTP/2 (browsers use h2; node https is h1)
41
+ const TRY_INSECURE = true; // retry ignoring TLS verification errors
42
  const DEBUG_ROUTE = true; // expose GET /debug/:enc — set false to disable
43
  // ================================================================
44
 
 
170
  extractFailed: 0, // got a body back but couldn't pull a subtitle out of it
171
  downloadsOK: 0,
172
  lastFailStage: null, // 'detail' | 'button' | 'fetch' | 'extract'
173
+ lastStatuses: [], // per-transport attempt records from the last download
174
  lastArchiveKind: null, // 'zip' | 'rar' | '7z' | 'gzip' | 'html' | 'unknown'
175
  lastNote: null, // human-readable reason the last attempt died
176
  };
 
209
  // identical from the outside.
210
  function _request(urlStr, opts = {}, redirects = 0, jar = '') {
211
  return new Promise((resolve, reject) => {
212
+ if (!urlStr || redirects > MAX_REDIRECTS) return reject(new Error('Redirect loop'));
213
  let parsed;
214
  try { parsed = new URL(urlStr); } catch (_) { return reject(new Error('Invalid URL')); }
215
+ if (Array.isArray(opts.chain)) opts.chain.push(urlStr);
216
  const lib = parsed.protocol === 'https:' ? https : http;
217
  const timeout = opts.timeout || REQ_TIMEOUT;
218
  const maxBytes = opts.maxBytes || MAX_BODY_BYTES;
 
223
  // 'application/zip,...' value on binary fetches was a fingerprintable tell.
224
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
225
  'Accept-Language': opts.lang === 'arabic' ? 'ar,en-US;q=0.8,en;q=0.6' : 'en-US,en;q=0.9',
226
+ // identity on binary fetches: a zip is already compressed, and asking for
227
+ // br/gzip on it only adds a decode path that can stall or throw.
228
+ 'Accept-Encoding': opts.binary ? 'identity' : 'gzip, deflate',
229
  'Referer': `${BASE}/`,
230
  'Connection': 'keep-alive',
231
  'Cache-Control': 'no-cache',
 
240
  method: 'GET',
241
  headers,
242
  };
243
+ if (opts.insecure && parsed.protocol === 'https:') reqOpts.rejectUnauthorized = false;
244
  const req = lib.request(reqOpts, res => {
245
  const status = res.statusCode;
246
  const carried = _cookiesFrom(res, cookie);
 
282
  });
283
  }
284
 
285
+ // ================================================================
286
+ // HTTP/2 TRANSPORT
287
+ // ================================================================
288
+ // Node's https module speaks HTTP/1.1. Every real browser negotiates h2, and
289
+ // edge WAFs fingerprint that difference. The detail page sails through on h1
290
+ // while /download hangs, so h2 is worth a genuine attempt rather than a guess.
291
+ function _h2request(urlStr, opts = {}, redirects = 0) {
292
+ return new Promise((resolve, reject) => {
293
+ if (!urlStr || redirects > MAX_REDIRECTS) return reject(new Error('Redirect loop'));
294
+ let parsed;
295
+ try { parsed = new URL(urlStr); } catch (_) { return reject(new Error('Invalid URL')); }
296
+ if (parsed.protocol !== 'https:') return reject(new Error('h2 needs https'));
297
+ if (Array.isArray(opts.chain)) opts.chain.push(`h2:${urlStr}`);
298
+ const timeout = opts.timeout || DL_HARD_TIMEOUT;
299
+ const maxBytes = opts.maxBytes || MAX_BODY_BYTES;
300
+ let settled = false;
301
+ let session;
302
+ const finish = (fn, arg) => {
303
+ if (settled) return;
304
+ settled = true;
305
+ try { if (session) session.destroy(); } catch (_) {}
306
+ fn(arg);
307
+ };
308
+ try {
309
+ session = http2.connect(parsed.origin, {
310
+ rejectUnauthorized: opts.insecure ? false : true,
311
+ });
312
+ } catch (e) { return reject(e); }
313
+ session.setTimeout(timeout, () => finish(reject, new Error('h2 session timeout')));
314
+ session.on('error', e => finish(reject, e));
315
+ const hdrs = {
316
+ ':method': 'GET',
317
+ ':authority': parsed.host,
318
+ ':scheme': 'https',
319
+ ':path': parsed.pathname + parsed.search,
320
+ 'user-agent': UA,
321
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
322
+ 'accept-language': 'en-US,en;q=0.9',
323
+ 'accept-encoding': opts.binary ? 'identity' : 'gzip, deflate',
324
+ ...(opts.h2headers || {}),
325
+ };
326
+ let req;
327
+ try { req = session.request(hdrs); } catch (e) { return finish(reject, e); }
328
+ req.setTimeout(timeout, () => finish(reject, new Error('h2 stream timeout')));
329
+ let status = 0, respHeaders = {};
330
+ req.on('response', h => { respHeaders = h; status = Number(h[':status']) || 0; });
331
+ const chunks = [];
332
+ let total = 0;
333
+ req.on('data', c => {
334
+ total += c.length;
335
+ if (total > maxBytes) { req.close(http2.constants.NGHTTP2_CANCEL); return; }
336
+ chunks.push(c);
337
+ });
338
+ req.on('error', e => finish(reject, e));
339
+ req.on('end', () => {
340
+ let buf = Buffer.concat(chunks);
341
+ const loc = respHeaders['location'];
342
+ if (status >= 300 && status < 400 && loc) {
343
+ settled = true;
344
+ try { session.destroy(); } catch (_) {}
345
+ return _h2request(new URL(loc, urlStr).toString(), opts, redirects + 1).then(resolve).catch(reject);
346
+ }
347
+ if (status !== 200) return finish(resolve, { status, headers: respHeaders, body: null, cookies: '' });
348
+ const enc = String(respHeaders['content-encoding'] || '').toLowerCase();
349
+ try {
350
+ if (enc.includes('gzip')) buf = zlib.gunzipSync(buf);
351
+ else if (enc.includes('br')) buf = zlib.brotliDecompressSync(buf);
352
+ else if (enc.includes('deflate')) buf = zlib.inflateSync(buf);
353
+ } catch (e) { return finish(reject, e); }
354
+ finish(resolve, { status, headers: respHeaders, body: opts.binary ? buf : buf.toString('utf8'), cookies: '' });
355
+ });
356
+ req.end();
357
+ });
358
+ }
359
+
360
  // Returns { body, status, cookies }. body is null when the fetch didn't yield a 200.
361
  async function getHTMLEx(url, tries = 3, lang = 'english', cookies = '') {
362
  let status = 0, jar = cookies;
 
377
  return (await getHTMLEx(url, tries, lang)).body;
378
  }
379
 
380
+ function _errLabel(e) {
381
+ if (!e) return 'unknown';
382
+ const code = e.code ? String(e.code) : '';
383
+ const msg = e.message ? String(e.message).slice(0, 120) : '';
384
+ return code ? `${code}${msg && msg !== code ? ' (' + msg + ')' : ''}` : (msg || 'unknown');
385
+ }
386
+
387
+ // Tries genuinely different transports rather than hammering the same one.
388
+ // Each attempt records a real status or a real error code into `attempts`, so a
389
+ // hang, a TLS failure and a redirect loop are never again flattened into -1.
390
+ async function getBinary(url, referer, cookies, attempts) {
391
+ const rec = a => { if (Array.isArray(attempts)) attempts.push(a); };
392
+ const h1headers = {
393
  'Sec-Fetch-Dest': 'document',
394
  'Sec-Fetch-Mode': 'navigate',
395
  'Sec-Fetch-Site': 'same-origin',
396
  'Sec-Fetch-User': '?1',
397
  };
398
+ if (referer) h1headers.Referer = referer;
399
+ const h2headers = {
400
+ 'referer': referer || `${BASE}/`,
401
+ 'sec-fetch-dest': 'document',
402
+ 'sec-fetch-mode': 'navigate',
403
+ 'sec-fetch-site': 'same-origin',
404
+ 'sec-fetch-user': '?1',
405
+ };
406
+
407
+ const strategies = [];
408
  for (let i = 0; i < DL_ATTEMPTS; i++) {
409
+ strategies.push({
410
+ via: `h1#${i + 1}`,
411
+ run: () => _request(url, {
412
+ binary: true, timeout: DL_HARD_TIMEOUT, maxBytes: MAX_SRT_BYTES,
413
+ headers: h1headers, cookies, chain: [],
414
+ }),
415
+ });
416
+ }
417
+ if (TRY_HTTP2) {
418
+ strategies.push({
419
+ via: 'h2',
420
+ run: () => _h2request(url, {
421
+ binary: true, timeout: DL_HARD_TIMEOUT, maxBytes: MAX_SRT_BYTES, h2headers,
422
+ }),
423
+ });
424
+ }
425
+ if (TRY_INSECURE) {
426
+ strategies.push({
427
+ via: 'h1-insecure',
428
+ run: () => _request(url, {
429
+ binary: true, timeout: DL_HARD_TIMEOUT, maxBytes: MAX_SRT_BYTES,
430
+ headers: h1headers, cookies, insecure: true, chain: [],
431
+ }),
432
+ });
433
+ if (TRY_HTTP2) strategies.push({
434
+ via: 'h2-insecure',
435
+ run: () => _h2request(url, {
436
+ binary: true, timeout: DL_HARD_TIMEOUT, maxBytes: MAX_SRT_BYTES, h2headers, insecure: true,
437
+ }),
438
+ });
439
+ }
440
+
441
+ for (let i = 0; i < strategies.length; i++) {
442
+ const s = strategies[i];
443
  try {
444
+ const r = await s.run();
445
+ rec({ via: s.via, status: r.status, bytes: r.body ? r.body.length : 0,
446
+ type: r.headers?.['content-type'] || null });
447
+ if (r.body && r.body.length > 0) return r.body;
448
  // Only 404/503 are worth waiting out; anything else won't change on retry.
449
+ if (s.via.startsWith('h1#') && r.status !== 404 && r.status !== 503) continue;
450
  } catch (e) {
451
+ rec({ via: s.via, error: _errLabel(e) });
452
  }
453
+ if (s.via.startsWith('h1#') && i < DL_ATTEMPTS - 1) await sleep(DL_RETRY_DELAY);
454
  }
455
+
456
  const enc = encodeURIComponent(url);
457
  for (const base of [PROXY_1, PROXY_2, PROXY_3]) {
458
  if (!base) continue;
459
  try {
460
  const r = await _request(`${base}${enc}`, { binary: true, timeout: DL_TIMEOUT, maxBytes: MAX_SRT_BYTES });
461
+ rec({ via: `proxy ${new URL(base).hostname}`, status: r.status, bytes: r.body ? r.body.length : 0 });
462
+ if (r.body && r.body.length > 50 && archiveKind(r.body) !== 'html') return r.body;
463
+ } catch (e) {
464
+ rec({ via: `proxy ${new URL(base).hostname}`, error: _errLabel(e) });
465
  }
466
  }
467
  return null;
 
1096
  // ================================================================
1097
  // Walks the same path a browser does: load the subtitle detail page, read the
1098
  // real href off its download button, then request that URL carrying the detail
1099
+ // page as Referer. Every stage records what happened into `trace` so /debug can
1100
+ // show exactly where it broke and why.
1101
  async function downloadSubtitle(pageUrl, trace = {}) {
1102
  trace.pageUrl = pageUrl;
1103
 
 
1135
 
1136
  // Stage 3 — fetch the archive, pacing the request like a real click
1137
  await sleep(DL_STEP_DELAY);
1138
+ const attempts = [];
1139
+ const buf = await getBinary(downloadUrl, pageUrl, detail.cookies, attempts);
1140
+ trace.attempts = attempts;
1141
+ diag.lastStatuses = attempts;
1142
  if (!buf) {
1143
  diag.downloadFetchFailed++;
1144
+ const summary = attempts.map(a => `${a.via}=${a.error || a.status}`).join(', ');
1145
+ noteFail('fetch', summary || 'no attempts recorded');
1146
  trace.failedAt = 'fetch';
1147
  return null;
1148
  }
 
1223
  id: 'org.van.vansubf2mplus.steel',
1224
  name: 'VanSubF2M Steel',
1225
  description: 'Hardened Subf2m English & Arabic subtitles with release-aware matching and anti-AI filtering.',
1226
+ version: '3.3.0',
1227
  resources: ['subtitles'],
1228
  types: ['movie', 'series'],
1229
  idPrefixes: ['tt'],
 
1238
  let pageUrl = '';
1239
  try { pageUrl = Buffer.from(req.params.enc, 'base64url').toString('utf8'); }
1240
  catch (_) { return res.status(400).end('Bad request'); }
1241
+ // This carries the subtitle DETAIL page URL (not a pre-built /download URL) —
1242
  // the real download link is resolved fresh from that page in fetchSRT.
1243
  if (!/^https:\/\/subf2m\.co\/subtitles\/[^\s\/]+\/(?:english|arabic)\/\d+\/?$/i.test(pageUrl)) return res.status(403).end('Forbidden');
1244
  const content = await fetchSRT(pageUrl);
 
1265
  const trace = {};
1266
  let content = null;
1267
  try { content = await downloadSubtitle(pageUrl, trace); }
1268
+ catch (e) { trace.threw = _errLabel(e); }
1269
  res.json({ ok: !!content, trace, preview: content ? content.slice(0, 300) : null });
1270
  });
1271
  }
 
1357
  res.json({
1358
  ok: true,
1359
  service: 'VanSubF2M Steel',
1360
+ version: '3.3.0',
1361
  debugRoute: DEBUG_ROUTE ? '/debug/<same base64url as /sub/>' : 'disabled',
1362
  downloadDiagnostics: diag,
1363
  });