koishi-plugin-bns-blacklist 1.0.13 → 1.0.15

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/lib/index.js +129 -42
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -175,52 +175,124 @@ function bruteForceExtractStrings(buf) {
175
175
  return strings;
176
176
  }
177
177
  // ─── Row reconstruction ──────────────────────────────────
178
- // Heuristic: strings appear in row-major order in the protobuf.
179
- // We identify rows by looking for date patterns (YYYY/M/D) as row delimiters.
178
+ // Group strings into rows. Each row starts with a player name (contains CJK).
179
+ // Single-char strings between fields are protobuf metadata - skip them.
180
180
  function reconstructRows(strings, logger) {
181
- // Filter out metadata strings (colors, fonts, hex values, etc.)
182
- const dataStrings = strings.filter(s => {
183
- if (/^[0-9A-Fa-f]{6}$/.test(s))
184
- return false; // hex colors
185
- if (/^\d+\.\d+\.\d+$/.test(s))
186
- return false; // version numbers
187
- if (s === 'Microsoft YaHei' || s === '工作表1' || s.startsWith('BB08J2'))
181
+ // First pass: merge adjacent non-metadata strings into fields
182
+ const merged = [];
183
+ let buf = '';
184
+ for (const s of strings) {
185
+ // Skip single-char protobuf metadata
186
+ if (s.length === 1 && !/[一-鿿]/.test(s)) {
187
+ if (buf) {
188
+ merged.push(buf.trim());
189
+ buf = '';
190
+ }
191
+ }
192
+ else {
193
+ buf += (buf ? '' : '') + s;
194
+ }
195
+ }
196
+ if (buf.trim())
197
+ merged.push(buf.trim());
198
+ // Filter out header/metadata lines
199
+ const data = merged.filter(s => {
200
+ if (s.startsWith('工作表') || s === '序号' || s === '事发ID')
201
+ return false;
202
+ if (s === '服务器' || s === '小号1' || s === '小号2')
203
+ return false;
204
+ if (s === '小号3' || s === '小号4' || s === '上榜原因')
205
+ return false;
206
+ if (s === '副本' || s === '举证截图' || s === '时间')
188
207
  return false;
189
- if (s.length === 1 && /[\x00-\x1f]/.test(s))
208
+ if (s === '脚本混野队' || s.startsWith('——申诉'))
190
209
  return false;
191
- return s.trim().length >= 2;
210
+ return s.length >= 2;
192
211
  });
193
212
  if (logger)
194
- logger.info(`Data strings after filter: ${dataStrings.length}`);
195
- // Try to group into rows using date pattern as row delimiter
213
+ logger.info(`Filtered to ${data.length} fields: ${data.slice(0, 30).join(' ||| ')}`);
214
+ // Group into rows: a row starts when we see a field that looks like a player name
215
+ // (not a pure number, not a pure date, contains CJK, not starting with digits)
196
216
  const rows = [];
197
- let currentRow = [];
198
- const datePattern = /^\d{4}\/\d{1,2}\/\d{1,2}/;
199
- for (const s of dataStrings) {
200
- currentRow.push(s);
201
- if (datePattern.test(s)) {
202
- // Date marks end of a row, start new one
203
- if (currentRow.length >= 3) {
204
- rows.push([...currentRow]);
205
- }
206
- currentRow = [];
217
+ let current = [];
218
+ for (const s of data) {
219
+ const isPlayerName = /[一-鿿]/.test(s) && !/^\d{1,2}$/.test(s) && !/^\d{4}\/\d{1,2}\/\d{1,2}/.test(s) && !/^\d+$/.test(s);
220
+ if (isPlayerName && current.length > 0) {
221
+ // New row starting - save previous if it has enough data
222
+ if (current.length >= 2)
223
+ rows.push([...current]);
224
+ current = [];
207
225
  }
226
+ current.push(s);
208
227
  }
209
- // Don't forget the last row
210
- if (currentRow.length >= 3) {
211
- rows.push([...currentRow]);
212
- }
213
- // If date-based splitting didn't work, try fixed-width approach
214
- if (rows.length < 2) {
215
- // Assume each row has the same number of fields
216
- // Find the most common field count by looking for patterns
217
- const nonMetaStrings = dataStrings.filter(s => !/^\d+$/.test(s) && s.length > 1);
218
- if (logger)
219
- logger.info(`Non-meta strings: ${nonMetaStrings.join(' | ')}`);
220
- return [];
228
+ if (current.length >= 2)
229
+ rows.push([...current]);
230
+ if (logger)
231
+ logger.info(`Grouped into ${rows.length} rows`);
232
+ // Normalize rows to our expected format
233
+ const result = [];
234
+ for (const row of rows) {
235
+ if (row.length < 2)
236
+ continue;
237
+ // Expected: 0=name, 1=server, [2-5]=alts, then description, dungeon, date
238
+ const mapped = [];
239
+ mapped[0] = row[0] || ''; // name (player name)
240
+ mapped[1] = ''; // class - not in this sheet, try to extract from name like "九天揽月丷(剑士)"
241
+ mapped[2] = row[1] || ''; // server
242
+ // violation is embedded in description, we'll infer it
243
+ mapped[3] = ''; // violation (inferred from description)
244
+ // combine alts (positions 2-5 in original)
245
+ const alts = [];
246
+ for (let i = 2; i < row.length && i < 6; i++) {
247
+ if (row[i] && !/^\d{4}\/\d/.test(row[i]) && !/^\d+$/.test(row[i]))
248
+ alts.push(row[i]);
249
+ }
250
+ mapped[4] = alts.join('、'); // alts
251
+ // description: typically the field before dungeon name
252
+ const dungeonIdx = row.findIndex((s, idx) => idx > 2 && isDungeonName(s));
253
+ if (dungeonIdx > 2) {
254
+ mapped[5] = row.slice(6, dungeonIdx).filter(s => !/^\d+$/.test(s)).join(' '); // description
255
+ mapped[7] = row[dungeonIdx]; // dungeon
256
+ }
257
+ else {
258
+ mapped[5] = ''; // description
259
+ mapped[7] = '';
260
+ }
261
+ mapped[6] = ''; // witness
262
+ mapped[8] = row[row.length - 1] || ''; // date (last field)
263
+ // Infer violation type from description
264
+ mapped[3] = inferViolation(mapped[5]);
265
+ // Extract class from name if present
266
+ const classMatch = mapped[0].match(/[((]([^))]+)[))]/);
267
+ if (classMatch) {
268
+ mapped[1] = classMatch[1];
269
+ mapped[0] = mapped[0].replace(/[((][^))]+[))]/, '').trim();
270
+ }
271
+ result.push(mapped);
221
272
  }
222
- return rows;
273
+ return result;
274
+ }
275
+ function isDungeonName(s) {
276
+ const dungeons = ['铁拳', '狗头', '狼人P3', '冰库', '雪人', '36金', '狼人', '—'];
277
+ return dungeons.some(d => s.includes(d)) && s.length <= 10;
278
+ }
279
+ function inferViolation(desc) {
280
+ const d = desc || '';
281
+ if (/脚本/.test(d))
282
+ return '脚本';
283
+ if (/跳车/.test(d))
284
+ return '跳车';
285
+ if (/双开/.test(d))
286
+ return '双开';
287
+ if (/挂机/.test(d))
288
+ return '挂机';
289
+ if (/嘴臭|骂/.test(d))
290
+ return '嘴臭';
291
+ if (/打铁/.test(d))
292
+ return '打铁';
293
+ return '其他';
223
294
  }
295
+ // ─── Row reconstruction ends ─────────────────────────────
224
296
  // ─── KV Format Parser ────────────────────────────────────
225
297
  function parseTencentKvFormat(text, logger) {
226
298
  const parts = text.split('\n').map(s => s.trim()).filter(s => s.length > 0);
@@ -378,20 +450,35 @@ ${itemsHtml}
378
450
  // ─── Scraping ────────────────────────────────────────────
379
451
  async function scrapeSheet(page, url, logger) {
380
452
  await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
381
- await new Promise(r => setTimeout(r, 8000));
453
+ // Wait longer for the sheet data to fully load
454
+ await new Promise(r => setTimeout(r, 15000));
382
455
  if (logger)
383
456
  logger.info(`Page: "${await page.title()}"`);
384
- // Fetch the dop-api data
385
- const apiText = await page.evaluate(async () => {
386
- const resp = await fetch(`https://docs.qq.com/dop-api/sheet/data?id=DZXRuRmtTS21mWU9V`, { credentials: 'include' });
387
- return await resp.text();
388
- });
457
+ // Fetch the dop-api data - retry a few times in case of incomplete load
458
+ let apiText = '';
459
+ for (let attempt = 0; attempt < 3; attempt++) {
460
+ apiText = await page.evaluate(async () => {
461
+ const resp = await fetch(`https://docs.qq.com/dop-api/sheet/data?id=DZXRuRmtTS21mWU9V`, { credentials: 'include' });
462
+ return await resp.text();
463
+ });
464
+ if (apiText.length > 5000)
465
+ break;
466
+ if (logger)
467
+ logger.info(`API response too short (${apiText.length}), retrying...`);
468
+ await new Promise(r => setTimeout(r, 3000));
469
+ }
470
+ if (logger)
471
+ logger.info(`API response: ${apiText.length} bytes`);
389
472
  const kvData = parseTencentKvFormat(apiText, logger);
390
473
  if (!kvData) {
391
474
  if (logger)
392
475
  logger.info('KV parse failed');
393
476
  return [];
394
477
  }
478
+ // Log all keys and sizes
479
+ const kvInfo = Object.entries(kvData).map(([k, v]) => `${k}(${v.length})`).join(', ');
480
+ if (logger)
481
+ logger.info(`KV keys: ${kvInfo}`);
395
482
  const chunkKey = Object.keys(kvData).find(k => k.startsWith('chunk_'));
396
483
  if (!chunkKey) {
397
484
  if (logger)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-bns-blacklist",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "剑灵怀旧服副本黑名单查询 - 从腾讯文档实时拉取数据,输入角色名即可生成黑名单截图",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",