amenbo 0.3.0 → 0.4.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.
@@ -75,7 +75,9 @@ function collectQualityInput(document, geometry) {
75
75
  extractedTokenEstimate: 0,
76
76
  };
77
77
  }
78
- // 元のDOMを変更しないよう、複製上でscript/style/noscriptを取り除いてから可視テキストを測る
78
+ // script/styleはこの時点で既にremoveStyleLeakElementsにより除去済み(ここでの除去は防御的で
79
+ // 実質no-op)。noscriptはReadability.parse()の画像復元(_unwrapNoscriptImages)のため意図的に
80
+ // 残してあるので、統計を歪めないよう複製上でのみ除去する(元のDOMは変更しない)。
79
81
  const clone = body.cloneNode(true);
80
82
  for (const el of Array.from(clone.querySelectorAll("script, style, noscript"))) {
81
83
  el.remove();
@@ -139,6 +141,11 @@ function extractByGeometry(document, geometry) {
139
141
  const ANCHOR_MAX_LENGTH = 80;
140
142
  const ANCHOR_MIN_PARAGRAPH_LENGTH = 30;
141
143
  const MAX_SPAN = 1000;
144
+ const PROBE_MIN_LENGTH = 8;
145
+ const PROBE_MAX_COUNT = 3;
146
+ const PROBE_MAX_LENGTH = 60;
147
+ // アンカー長(見出し用)とは別概念のため定数を分ける(片方の調整が他方へ波及しないように)。
148
+ const PROBE_FALLBACK_MAX_LENGTH = 80;
142
149
  /** colspan/rowspan属性値を1以上MAX_SPAN以下の整数へ丸める(異常値ガード)。 */
143
150
  function clampSpan(value) {
144
151
  const n = Number.parseInt(value ?? "1", 10);
@@ -148,8 +155,18 @@ function clampSpan(value) {
148
155
  }
149
156
  /**
150
157
  * turndownのGFMテーブル変換はcolspan/rowspan・多段ヘッダを扱えず列ズレや空セルを生む。
151
- * グリッドを再構築してspanを実セルへ展開し、先頭の連続ヘッダ行は1行へ結合してから
152
- * 全行同一列数の単純表を返す。救出経路の表のみに適用する(影響を局所化)。
158
+ * グリッドを再構築してspanを「位置の占有」として展開し(内容の複製はしない)、先頭の連続ヘッダ行は
159
+ * 1行へ結合してから全行同一列数の単純表を返す。救出経路(collectDataTables)とReadability/アダプタ等が
160
+ * 本文に保持した複雑表(normalizeRetainedTables)の双方から呼ばれる。
161
+ * spanの無い単純表・既に正規化済みの表に対しては構造を変えず冪等に振る舞う。
162
+ * captionは再構築で失われないよう先頭へ復元する。
163
+ *
164
+ * 内容配置は行の役割で分岐する:
165
+ * - 列ヘッダ行(all-th ∧ 起点セル2つ以上): 占有読み。colspanグループラベルは配下の各列へ複製される
166
+ * (多段ヘッダ結合「グループ 列名」の意味を保つための唯一の複製)
167
+ * - データ行: 起点位置にのみ内容、span継続位置は空セル(複製しない)
168
+ * - 全幅単一th行: 列ヘッダではなく表題/節ラベル。先頭の連続分はcaptionへ畳み(turndown-gfmが
169
+ * ヘッダ行を見失って複雑表フォールバックへ落ちるのを防ぐ)、表中間の区切り行はデータ行として出力
153
170
  */
154
171
  export function normalizeTableHtml(tableHtml) {
155
172
  const { document } = parseHTML(`<!DOCTYPE html><html><body>${tableHtml}</body></html>`);
@@ -157,23 +174,27 @@ export function normalizeTableHtml(tableHtml) {
157
174
  if (!tableEl)
158
175
  return tableHtml;
159
176
  const table = tableEl;
177
+ // 自表直下のcaptionのみ拾う(入れ子表のcaptionを巻き込まない)。
178
+ const caption = Array.from(document.querySelectorAll("caption")).find((cap) => cap.closest("table") === tableEl);
160
179
  // 入れ子の表の行を巻き込まないよう、直接の行(closestが自表と一致)だけを対象にする。
161
180
  const rows = Array.from(table.querySelectorAll("tr")).filter((tr) => tr.closest("table") === tableEl);
162
181
  const rowCount = rows.length;
163
182
  if (rowCount === 0)
164
183
  return tableHtml;
165
184
  const grid = Array.from({ length: rowCount }, () => []);
166
- const isHeaderRow = [];
185
+ const allTh = [];
186
+ const originCount = [];
167
187
  for (let r = 0; r < rowCount; r++) {
168
188
  const cells = Array.from(rows[r].children).filter((el) => el.tagName === "TD" || el.tagName === "TH");
169
- isHeaderRow[r] = cells.length > 0 && cells.every((cell) => cell.tagName === "TH");
189
+ allTh[r] = cells.length > 0 && cells.every((cell) => cell.tagName === "TH");
190
+ originCount[r] = cells.length;
170
191
  let c = 0;
171
192
  for (const cell of cells) {
172
193
  while (grid[r][c] !== undefined)
173
194
  c++;
174
195
  const colspan = clampSpan(cell.getAttribute("colspan"));
175
196
  const rowspan = clampSpan(cell.getAttribute("rowspan"));
176
- const filled = { html: cell.innerHTML.trim() };
197
+ const filled = { html: cell.innerHTML.trim(), originRow: r, originCol: c };
177
198
  for (let i = 0; i < rowspan && r + i < rowCount; i++) {
178
199
  for (let j = 0; j < colspan; j++) {
179
200
  grid[r + i][c + j] = filled;
@@ -185,37 +206,93 @@ export function normalizeTableHtml(tableHtml) {
185
206
  const numCols = grid.reduce((max, row) => Math.max(max, row.length), 0);
186
207
  if (numCols === 0)
187
208
  return tableHtml;
209
+ const occupancyCell = (r, c) => grid[r]?.[c]?.html ?? "";
210
+ const originCellHtml = (r, c) => {
211
+ const g = grid[r]?.[c];
212
+ return g && g.originRow === r && g.originCol === c ? g.html : "";
213
+ };
214
+ // 列ヘッダ行: all-th ∧ 起点セル2つ以上。単一全幅th行(起点セル1つ)は列ヘッダではなくラベル行。
215
+ const isColumnHeaderRow = (r) => allTh[r] && originCount[r] >= 2;
216
+ const isFullWidthLabelRow = (r) => {
217
+ if (numCols < 2 || !allTh[r] || originCount[r] !== 1)
218
+ return false;
219
+ let covered = 0;
220
+ for (let c = 0; c < numCols; c++)
221
+ if (grid[r]?.[c]?.originRow === r)
222
+ covered++;
223
+ return covered === numCols;
224
+ };
225
+ const emitRow = (cells, tag) => `<tr>${cells.map((h) => `<${tag}>${h}</${tag}>`).join("")}</tr>`;
226
+ const emitOne = (r) => {
227
+ // thは列ヘッダ行にのみ与える。単一起点のラベル行をthのまま出力すると、空th兄弟を伴う
228
+ // 「all-th複数セル行」として実体化し、再パース時(救出挿入後のnormalizeRetainedTables)に
229
+ // 列ヘッダと誤認されて多段ヘッダ結合へ巻き込まれる=区切りラベルが消失するため。
230
+ const tag = isColumnHeaderRow(r) ? "th" : "td";
231
+ const read = isColumnHeaderRow(r) ? occupancyCell : originCellHtml;
232
+ return emitRow(Array.from({ length: numCols }, (_, c) => read(r, c)), tag);
233
+ };
234
+ // 先頭の連続する全幅単一thラベル行をcaptionへ畳む。全行がラベル行の退化ケースでは畳まない。
235
+ const foldedCaptions = [];
236
+ let start = 0;
237
+ while (start < rowCount && isFullWidthLabelRow(start)) {
238
+ const text = occupancyCell(start, 0);
239
+ if (text)
240
+ foldedCaptions.push(text);
241
+ start++;
242
+ }
243
+ if (start >= rowCount) {
244
+ start = 0;
245
+ foldedCaptions.length = 0;
246
+ }
188
247
  let headerCount = 0;
189
- while (headerCount < rowCount && isHeaderRow[headerCount])
248
+ while (start + headerCount < rowCount && isColumnHeaderRow(start + headerCount))
190
249
  headerCount++;
191
- const cellHtml = (r, c) => grid[r]?.[c]?.html ?? "";
192
- const out = ["<table>"];
193
- const emitRow = (cells, tag) => {
194
- out.push(`<tr>${cells.map((h) => `<${tag}>${h}</${tag}>`).join("")}</tr>`);
195
- };
196
- if (headerCount >= 2) {
197
- // 多段ヘッダ: 列ごとに上→下のセルを重複除去して連結し、1行のヘッダへ畳む。
250
+ const headerRows = [];
251
+ const bodyRows = [];
252
+ // 全行がヘッダ(start+headerCount === rowCount)の表を結合するとデータ行が消えるため、
253
+ // 結合はデータ行が1行以上残る場合に限る。全ヘッダ表はelse分岐で先頭行のみthead扱いにし、
254
+ // 残りは各行を保持する(スタイル目的でthを使う表の保護)。
255
+ if (headerCount >= 2 && start + headerCount < rowCount) {
256
+ // 多段ヘッダ: 列ごとに上→下のセルを占有読みし、重複除去して連結して1行のヘッダへ畳む。
198
257
  const merged = [];
199
258
  for (let c = 0; c < numCols; c++) {
200
259
  const parts = [];
201
- for (let r = 0; r < headerCount; r++) {
202
- const h = cellHtml(r, c);
260
+ for (let r = start; r < start + headerCount; r++) {
261
+ const h = occupancyCell(r, c);
203
262
  if (h && parts[parts.length - 1] !== h)
204
263
  parts.push(h);
205
264
  }
206
265
  merged.push(parts.join(" "));
207
266
  }
208
- emitRow(merged, "th");
209
- for (let r = headerCount; r < rowCount; r++) {
210
- emitRow(Array.from({ length: numCols }, (_, c) => cellHtml(r, c)), "td");
211
- }
267
+ headerRows.push(emitRow(merged, "th"));
268
+ for (let r = start + headerCount; r < rowCount; r++)
269
+ bodyRows.push(emitOne(r));
212
270
  }
213
271
  else {
214
- // ヘッダ0/1行: spanの展開のみ行い、行のth/tdは元の判定を踏襲する。
215
- for (let r = 0; r < rowCount; r++) {
216
- emitRow(Array.from({ length: numCols }, (_, c) => cellHtml(r, c)), isHeaderRow[r] ? "th" : "td");
272
+ // ヘッダ0/1行(全ヘッダ表を含む): 先頭が列ヘッダ行ならthead、残りはtbodyへ。
273
+ for (let r = start; r < rowCount; r++) {
274
+ if (r === start && isColumnHeaderRow(r))
275
+ headerRows.push(emitOne(r));
276
+ else
277
+ bodyRows.push(emitOne(r));
217
278
  }
218
279
  }
280
+ // captionを内包したままでもヘッダ行を見失わないよう、ヘッダはthead・データはtbodyで囲う
281
+ // (turndown-gfmはtrがtableの先頭子でないとヘッダと認識せず、captionがあると降格するため)。
282
+ const captionParts = [];
283
+ if (caption) {
284
+ const capHtml = caption.innerHTML.trim();
285
+ if (capHtml)
286
+ captionParts.push(capHtml);
287
+ }
288
+ captionParts.push(...foldedCaptions);
289
+ const out = ["<table>"];
290
+ if (captionParts.length > 0)
291
+ out.push(`<caption>${captionParts.join(" ")}</caption>`);
292
+ if (headerRows.length > 0)
293
+ out.push(`<thead>${headerRows.join("")}</thead>`);
294
+ if (bodyRows.length > 0)
295
+ out.push(`<tbody>${bodyRows.join("")}</tbody>`);
219
296
  out.push("</table>");
220
297
  return out.join("");
221
298
  }
@@ -226,6 +303,7 @@ function normalizeForSignature(text) {
226
303
  /**
227
304
  * document順に並べた要素列から、tableIndexの表の直前アンカー(見出し優先・無ければ近傍段落)を求める。
228
305
  * 見出しは節の先頭に表を戻すのに最適。見出しが皆無なページ用に段落をフォールバックにする。
306
+ * 由来(heading/paragraph)を返し、照合方法(完全一致/包含)を呼び出し側で出し分けられるようにする。
229
307
  */
230
308
  function findTableAnchor(seq, tableIndex) {
231
309
  let paragraph = "";
@@ -236,13 +314,39 @@ function findTableAnchor(seq, tableIndex) {
236
314
  const text = normalizeForSignature(el.textContent);
237
315
  if (/^H[1-6]$/.test(el.tagName)) {
238
316
  if (text.length > 0)
239
- return text.slice(0, ANCHOR_MAX_LENGTH); // 見出し優先。
317
+ return { text: text.slice(0, ANCHOR_MAX_LENGTH), kind: "heading" }; // 見出し優先。
240
318
  }
241
319
  else if (!paragraph && text.length >= ANCHOR_MIN_PARAGRAPH_LENGTH) {
242
320
  paragraph = text.slice(0, ANCHOR_MAX_LENGTH);
243
321
  }
244
322
  }
245
- return paragraph;
323
+ return paragraph ? { text: paragraph, kind: "paragraph" } : { text: "", kind: "" };
324
+ }
325
+ /**
326
+ * 表内セル(th/td)から重複判定用の照合プローブを作る。入れ子表を内包するセルは、その内側の
327
+ * 表全体がinnerHTMLに入り込みノイズになる(かつ内側セルは個別に拾える)ため除外する。
328
+ * 長さ8字以上のセルテキストを重複除去し、長い順(安定ソート)に最大3本、各60字へ切り詰める。
329
+ * 対象セルが1つも無い表は、従来のouterHTML先頭を単一プローブとしてフォールバックする。
330
+ */
331
+ function buildTableProbes(table) {
332
+ const cells = Array.from(table.querySelectorAll("th, td")).filter((cell) => cell.querySelectorAll("table").length === 0);
333
+ const seen = new Set();
334
+ const probes = [];
335
+ for (const cell of cells) {
336
+ const text = normalizeForSignature(cell.innerHTML);
337
+ if (text.length < PROBE_MIN_LENGTH || seen.has(text))
338
+ continue;
339
+ seen.add(text);
340
+ probes.push(text);
341
+ }
342
+ if (probes.length === 0) {
343
+ return [normalizeForSignature(table.outerHTML).slice(0, PROBE_FALLBACK_MAX_LENGTH)].filter((p) => p.length > 0);
344
+ }
345
+ return probes
346
+ .map((probe, index) => ({ probe, index }))
347
+ .sort((a, b) => b.probe.length - a.probe.length || a.index - b.index)
348
+ .slice(0, PROBE_MAX_COUNT)
349
+ .map(({ probe }) => probe.slice(0, PROBE_MAX_LENGTH));
246
350
  }
247
351
  /**
248
352
  * Readabilityが取りこぼしがちなデータ表を、救出候補としてHTML文字列+位置アンカーで採取する。
@@ -271,26 +375,62 @@ export function collectDataTables(body) {
271
375
  }
272
376
  if (maxCells < 2)
273
377
  continue;
378
+ const anchor = findTableAnchor(seq, i);
274
379
  result.push({
275
380
  html: normalizeTableHtml(table.outerHTML),
276
- // 重複判定は正規化前の元テキスト基準にする(Readability保持時の照合を安定させる)。
277
- signature: normalizeForSignature(table.textContent).slice(0, ANCHOR_MAX_LENGTH),
278
- anchor: findTableAnchor(seq, i),
381
+ probes: buildTableProbes(table),
382
+ anchor: anchor.text,
383
+ anchorKind: anchor.kind,
279
384
  });
280
385
  }
281
386
  return result;
282
387
  }
388
+ /**
389
+ * 表のプローブの真の過半数(floor(n/2)+1)が本文に含まれていれば「既に本文にある」と判定する
390
+ * (プローブ0本は既存扱い)。ceil(n/2)だとn=2のとき1本一致(50%)で既存と判定され、本文の地の文と
391
+ * 偶然一致した1本のせいで本当に落ちた表が救出されない=サイレント欠落に倒れる。重複挿入(可視・
392
+ * 有界)よりデータ欠落(不可視)の方が害が大きいため、偶数本では厳しい側に倒す。
393
+ */
394
+ function isTableAlreadyPresent(table, contentText) {
395
+ if (table.probes.length === 0)
396
+ return true;
397
+ let hits = 0;
398
+ for (const probe of table.probes) {
399
+ if (probe.length > 0 && contentText.includes(probe))
400
+ hits++;
401
+ }
402
+ return hits >= Math.floor(table.probes.length / 2) + 1;
403
+ }
404
+ /**
405
+ * 表アンカーの照合。heading由来は見出し要素(h1-h6)の正規化テキスト完全一致に限定する
406
+ * (「人口」等の短く非一意な見出しが段落の部分文字列へ吸着し、表が無関係な節へ誤挿入されるのを防ぐ。
407
+ * 実測: zh.wikipedia 广东省で人口統計表が航空節へ誤挿入)。anchorが80字で切り詰められている場合のみ
408
+ * 前方一致を許す。paragraph由来は従来どおり包含判定する。
409
+ */
410
+ function matchesTableAnchor(node, table) {
411
+ if (table.anchor.length === 0)
412
+ return false;
413
+ const nodeText = normalizeForSignature(node.textContent);
414
+ if (table.anchorKind === "heading") {
415
+ if (!/^H[1-6]$/.test(node.tagName))
416
+ return false;
417
+ return nodeText === table.anchor || (table.anchor.length === ANCHOR_MAX_LENGTH && nodeText.startsWith(table.anchor));
418
+ }
419
+ return nodeText.includes(table.anchor);
420
+ }
283
421
  /**
284
422
  * Readability出力に含まれない表だけを、可能なら元の位置(アンカー直後)へ、無理なら末尾へ再結合する。
285
423
  * アンカー(表の直前見出し/段落)がReadability出力に残っていれば、その要素の直後に挿入して
286
- * 「表が本来属していた節」へ戻す(記事構造=意味の保持)。残っていなければ末尾フォールバック。
424
+ * 「表が本来属していた節」へ戻す(記事構造=意味の保持)。残っていなければ末尾フォールバック
425
+ * (誤配置より末尾が安全)。
287
426
  */
288
427
  export function reinsertDroppedTables(contentHtml, tables) {
289
428
  if (tables.length === 0)
290
429
  return { html: contentHtml, appended: 0 };
291
- // Readabilityが既に保持している表はシグネチャ(元テキスト先頭)で除外する。
430
+ // Readabilityが既に保持している表はプローブ多数決で除外する。Readabilityが一部セルを刈っても
431
+ // 過半数のプローブが本文に残っていれば「既にある」と判定する。
292
432
  const contentText = normalizeForSignature(contentHtml);
293
- const missing = tables.filter((table) => table.signature.length > 0 && !contentText.includes(table.signature));
433
+ const missing = tables.filter((table) => !isTableAlreadyPresent(table, contentText));
294
434
  if (missing.length === 0)
295
435
  return { html: contentHtml, appended: 0 };
296
436
  const { document } = parseHTML(`<!DOCTYPE html><html><body>${contentHtml}</body></html>`);
@@ -298,9 +438,7 @@ export function reinsertDroppedTables(contentHtml, tables) {
298
438
  const anchorNodes = Array.from(host.body.querySelectorAll("h1, h2, h3, h4, h5, h6, p"));
299
439
  const tail = [];
300
440
  for (const table of missing) {
301
- const anchorNode = table.anchor
302
- ? anchorNodes.find((node) => normalizeForSignature(node.textContent).includes(table.anchor))
303
- : undefined;
441
+ const anchorNode = anchorNodes.find((node) => matchesTableAnchor(node, table));
304
442
  if (anchorNode) {
305
443
  anchorNode.insertAdjacentHTML("afterend", table.html);
306
444
  }
@@ -312,9 +450,343 @@ export function reinsertDroppedTables(contentHtml, tables) {
312
450
  const html = tail.length > 0 ? `${inserted}\n${tail.join("\n")}` : inserted;
313
451
  return { html, appended: missing.length };
314
452
  }
453
+ function isNestedInTable(el) {
454
+ // TABLE自身はclosestが自分を返すため祖先表は親から探す。他要素はel自身から探す。
455
+ const from = el.tagName === "TABLE" ? el.parentElement : el;
456
+ return from?.closest("table") != null;
457
+ }
458
+ /** 見出しレベル(H1-H6→1-6)を返す。見出しでなければ0。 */
459
+ function headingLevel(tagName) {
460
+ const match = /^H([1-6])$/.exec(tagName);
461
+ return match ? Number(match[1]) : 0;
462
+ }
463
+ const HEADING_ANCHOR_SELECTOR = "h1, h2, h3, h4, h5, h6, p, table, ul, ol, dl";
464
+ const HEADING_ANCHOR_CANDIDATE_MAX = 4;
465
+ /**
466
+ * document順の要素列から、headingIndexの見出しの節スコープ内アンカーを求める。
467
+ * スコープは「自レベル以下(数値がlevel以下)の見出しに達するまで」。子見出し(数値が大きい)は
468
+ * 跨いで走査を続ける(親h2の直後に子h3が続く構造でも親の段落まで辿り着けるようにするため)。
469
+ * 段落(p)を最優先し、無ければ表/リスト等ブロックの先頭をフォールバックにする(表・リストのみの節用)。
470
+ * 表の内側の要素はanchorにしない(挿入先から除外されるため命中しても復元できない)。
471
+ * 由来(paragraph/block)を返し、照合方法を呼び出し側で出し分けられるようにする。無ければ空。
472
+ */
473
+ function findHeadingAnchor(seq, headingIndex) {
474
+ const level = headingLevel(seq[headingIndex]?.tagName ?? "");
475
+ const candidates = [];
476
+ for (let i = headingIndex + 1; i < seq.length; i++) {
477
+ const el = seq[i];
478
+ if (!el)
479
+ continue;
480
+ const elLevel = headingLevel(el.tagName);
481
+ if (elLevel > 0) {
482
+ if (elLevel <= level)
483
+ break; // 同レベル/上位の見出し=節の終端。子見出しは跨いで継続。
484
+ continue;
485
+ }
486
+ if (isNestedInTable(el))
487
+ continue;
488
+ const text = normalizeForSignature(el.textContent);
489
+ if (text.length < ANCHOR_MIN_PARAGRAPH_LENGTH)
490
+ continue;
491
+ const kind = el.tagName === "P" ? "paragraph" : "block";
492
+ candidates.push({ text: text.slice(0, ANCHOR_MAX_LENGTH), kind });
493
+ // 段落は照合が安定しているため、最初の段落が候補に入ったら打ち切る(それが落ちている節は
494
+ // 本文ごと消えているとみなす)。ブロックだけが続く節は上限まで候補を積む。
495
+ if (kind === "paragraph" || candidates.length >= HEADING_ANCHOR_CANDIDATE_MAX)
496
+ break;
497
+ }
498
+ return candidates;
499
+ }
500
+ /**
501
+ * Readabilityは見出しがラッパー要素(編集リンク付きdiv等)に包まれていると本文整形時にh2-h6を
502
+ * 丸ごと落とし、outline/section機能が依存する節構造を全消失させる。parse前のdocumentからh2-h6を
503
+ * 採取し、節スコープ内の段落/ブロックをアンカーにして位置復元に備える。
504
+ * parseは渡したdocumentを破壊的に変更するため、必ずparse前に呼ぶこと。
505
+ * h1はReadabilityがtitleへ昇格させるため対象外(復元するとtitleと二重化する)。
506
+ * 見出し内リンク等のmarkupは持たない(outlineに要るのはテキストと階層のみ)。
507
+ */
508
+ export function collectHeadings(body) {
509
+ const result = [];
510
+ const seq = Array.from(body.querySelectorAll(HEADING_ANCHOR_SELECTOR));
511
+ for (let i = 0; i < seq.length; i++) {
512
+ const el = seq[i];
513
+ if (!el)
514
+ continue;
515
+ const level = headingLevel(el.tagName);
516
+ if (level < 2)
517
+ continue; // h1(title二重化)と非見出し(p/表/リスト)を除外。
518
+ const text = normalizeForSignature(el.textContent);
519
+ if (text.length === 0)
520
+ continue; // 空見出しは捨てる。
521
+ result.push({ level, text, anchors: findHeadingAnchor(seq, i) });
522
+ }
523
+ return result;
524
+ }
525
+ const HEADING_INSERT_TARGET_SELECTOR = "p, blockquote, table, ul, ol, dl";
526
+ /**
527
+ * 見出しアンカーの照合を由来で出し分ける。
528
+ * block(表/リスト)由来は両辺の全空白を除去して包含判定する。normalizeTableHtmlで再構築された表は
529
+ * セル間に空白が入らないため、生DOM textContent由来のanchorと空白がズレて通常の照合が外れる
530
+ * (実測: 表由来anchorが全滅)。CJKでは空白の意味が薄く除去による誤一致リスクは無視できるが、
531
+ * 欧文での偽陽性を避けるため段落由来には広げず、paragraph由来はnormalizeForSignatureベースの包含に留める。
532
+ */
533
+ function matchesAnchorText(nodeText, anchor, kind) {
534
+ if (anchor.length === 0)
535
+ return false;
536
+ if (kind === "block") {
537
+ const needle = anchor.replace(/\s+/g, "");
538
+ return needle.length > 0 && nodeText.replace(/\s+/g, "").includes(needle);
539
+ }
540
+ return normalizeForSignature(nodeText).includes(anchor);
541
+ }
542
+ /**
543
+ * Readabilityが落とした見出しを、アンカー(直後段落)がReadability出力に残っている場合のみ、
544
+ * その段落の直前(beforebegin)へ`<hN>text</hN>`として戻す。
545
+ *
546
+ * 表救出(reinsertDroppedTables)との最重要差分は「アンカー不在の見出しは復元しない・末尾
547
+ * フォールバックもしない」こと。表=データそのもの(失うと情報欠落)だが、見出し=節のラベルであり、
548
+ * 節本文がReadability出力に残っていない見出しを戻してもノイズにしかならない。アンカーの生存確認が
549
+ * 「その節が本文として残っているか」の判定を兼ね、nav/フッター等Readabilityが正しく落とした節の
550
+ * 見出しを自然に除外する(サイト固有知識なしでnavbox見出しを拾わない)。
551
+ * 同一テキストの見出しは最初の1件のみ扱う(同名節の複数復元はアンカー取り違えリスクの方が大きい)。
552
+ * 挿入は「同テキストの見出しが本文に無い」場合のみのため、再適用しても増えない(冪等)。
553
+ */
554
+ export function reinsertDroppedHeadings(contentHtml, headings) {
555
+ if (headings.length === 0)
556
+ return { html: contentHtml, restored: 0 };
557
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${contentHtml}</body></html>`);
558
+ const host = document;
559
+ const existing = new Set();
560
+ for (const el of Array.from(host.body.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
561
+ const text = normalizeForSignature(el.textContent);
562
+ if (text.length > 0)
563
+ existing.add(text);
564
+ }
565
+ // 表の内側の要素は挿入先から除外する(td内のpにマッチして見出しが表の中へ入り込むのを防ぐ)。
566
+ const anchorNodes = Array.from(host.body.querySelectorAll(HEADING_INSERT_TARGET_SELECTOR)).filter((node) => !isNestedInTable(node));
567
+ const handled = new Set();
568
+ let restored = 0;
569
+ for (const heading of headings) {
570
+ if (handled.has(heading.text))
571
+ continue; // 同一テキストは最初の1件のみ扱う。
572
+ handled.add(heading.text);
573
+ if (existing.has(heading.text))
574
+ continue; // 既に本文にある見出しは戻さない。
575
+ if (heading.anchors.length === 0)
576
+ continue; // アンカー不在=節本文が残っていないので復元しない。
577
+ // 候補を文書順に試し、最初に一致した要素の直前へ挿入する(節先頭のブロックが落ちていれば
578
+ // 後続のデータ表・段落へ吸着する)。
579
+ let anchorNode;
580
+ for (const candidate of heading.anchors) {
581
+ anchorNode = anchorNodes.find((node) => matchesAnchorText(node.textContent, candidate.text, candidate.kind));
582
+ if (anchorNode)
583
+ break;
584
+ }
585
+ if (!anchorNode)
586
+ continue; // 全候補が出力に無い=その節は落とされているので復元しない。
587
+ anchorNode.insertAdjacentHTML("beforebegin", `<h${heading.level}>${escapeHtml(heading.text)}</h${heading.level}>`);
588
+ restored++;
589
+ }
590
+ return restored > 0 ? { html: host.body.innerHTML, restored } : { html: contentHtml, restored: 0 };
591
+ }
592
+ /**
593
+ * 正規化が必要な「データ表」かを判定する。2行2列以上かつ、colspan/rowspanを持つか
594
+ * 「先頭に2行以上の連続ヘッダ行があり、かつその後にデータ行が残る(多段ヘッダ)」表のみtrue。
595
+ * 単純表・レイアウト表・全行がヘッダの表は、turndownに任せて差分と誤破壊を避けるため弾く
596
+ * (全ヘッダ表を多段ヘッダとして畳むとデータ行が消えるため対象外)。
597
+ * 連続ヘッダ行の数え方はnormalizeTableHtmlの列ヘッダ定義(all-th ∧ セル2つ以上)に揃える
598
+ * (全幅単一th行を列ヘッダに数えると単一セルラベルが誤って多段ヘッダ扱いされる)。
599
+ */
600
+ function needsTableNormalization(table) {
601
+ const rows = Array.from(table.querySelectorAll("tr")).filter((tr) => tr.closest("table") === table);
602
+ if (rows.length < 2)
603
+ return false;
604
+ let maxCells = 0;
605
+ let hasSpan = false;
606
+ let leadingHeaderRows = 0;
607
+ let inLeadingHeaders = true;
608
+ for (const row of rows) {
609
+ const cells = Array.from(row.children).filter((el) => el.tagName === "TD" || el.tagName === "TH");
610
+ if (cells.length > maxCells)
611
+ maxCells = cells.length;
612
+ for (const cell of cells) {
613
+ if (clampSpan(cell.getAttribute("colspan")) > 1 || clampSpan(cell.getAttribute("rowspan")) > 1)
614
+ hasSpan = true;
615
+ }
616
+ const isColumnHeader = cells.length >= 2 && cells.every((cell) => cell.tagName === "TH");
617
+ if (inLeadingHeaders && isColumnHeader)
618
+ leadingHeaderRows++;
619
+ else
620
+ inLeadingHeaders = false;
621
+ }
622
+ if (maxCells < 2)
623
+ return false;
624
+ const hasMultiHeaderWithData = leadingHeaderRows >= 2 && leadingHeaderRows < rows.length;
625
+ return hasSpan || hasMultiHeaderWithData;
626
+ }
627
+ const CELL_BLOCK_SELECTOR = "ul, ol, h1, h2, h3, h4, h5, h6, hr, blockquote, pre";
628
+ /** テキストをHTMLへ差し戻す際の最小エスケープ(pre→codeでtextContentを埋め込むため)。 */
629
+ function escapeHtml(text) {
630
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
631
+ }
632
+ /**
633
+ * 直前に非空白の内容があり、かつそれが<br>でない場合にtrue。インライン化した置換文字列が
634
+ * 先行テキストと無境界に連結される(例: 親liのテキストと入れ子リスト先頭項目が「親1子1」化する)
635
+ * のを防ぐため、置換文字列の先頭に<br>を足すべきかの判定に使う。
636
+ */
637
+ function needsLeadingBreak(el) {
638
+ let prev = el.previousSibling;
639
+ while (prev) {
640
+ if (prev.nodeType === 1)
641
+ return prev.tagName !== "BR";
642
+ if (prev.nodeType === 3 && (prev.textContent ?? "").trim().length > 0)
643
+ return true;
644
+ prev = prev.previousSibling;
645
+ }
646
+ return false;
647
+ }
648
+ /** ブロック要素をGFM表セルに収まるインラインHTMLへ変換する。 */
649
+ function inlineReplacement(el) {
650
+ const tag = el.tagName;
651
+ // hr(それ自体が区切り)とblockquote(ブロック子要素を保持したままのunwrap)以外は純インラインに
652
+ // なるため、先行内容との境界を<br>で明示する。
653
+ const lead = tag !== "HR" && tag !== "BLOCKQUOTE" && needsLeadingBreak(el) ? "<br>" : "";
654
+ if (tag === "UL" || tag === "OL") {
655
+ // マーカー無しで各liを<br>連結する(番号・入れ子構造は落とし、テキストとリンクを残す)。
656
+ const items = Array.from(el.children)
657
+ .filter((child) => child.tagName === "LI")
658
+ .map((li) => li.innerHTML.trim())
659
+ .filter((html) => html.length > 0)
660
+ .join("<br>");
661
+ return lead + items;
662
+ }
663
+ if (/^H[1-6]$/.test(tag))
664
+ return `${lead}<strong>${el.innerHTML.trim()}</strong><br>`;
665
+ if (tag === "HR")
666
+ return "<br>";
667
+ if (tag === "BLOCKQUOTE")
668
+ return el.innerHTML; // unwrap(子を保持)。
669
+ return `${lead}<code>${escapeHtml(el.textContent.replace(/\s*\n\s*/g, " ").trim())}</code>`; // PRE。
670
+ }
671
+ /** rootからの深さ(祖先数)を返す。入れ子リスト等を深い方から平坦化する順序付けに使う。 */
672
+ function depthWithin(el, root) {
673
+ let depth = 0;
674
+ let cur = el.parentElement;
675
+ while (cur && cur !== root) {
676
+ depth++;
677
+ cur = cur.parentElement;
678
+ }
679
+ return depth;
680
+ }
681
+ /**
682
+ * 表のセル内ブロック要素(ul/ol/h1-6/hr/blockquote/pre)をインライン化する。
683
+ * turndown-gfmはこれらを含む表をtableShouldBeHtml判定でouterHTMLごとダンプする(joplin-table-wrapper
684
+ * のHTMLリーク)ため、プラグインを触らず入力側で発動条件を消す。入れ子表そのものは触らず、
685
+ * 入れ子表内の要素はその入れ子表を処理する呼び出しに委ねる(closestが自表と一致する要素のみ対象)。
686
+ * 入れ子リストは深い方から平坦化する。
687
+ */
688
+ function inlineCellBlocks(table) {
689
+ const targets = Array.from(table.querySelectorAll(CELL_BLOCK_SELECTOR)).filter((el) => el.closest("table") === table);
690
+ if (targets.length === 0)
691
+ return false;
692
+ targets.sort((a, b) => depthWithin(b, table) - depthWithin(a, table));
693
+ for (const el of targets) {
694
+ el.insertAdjacentHTML("beforebegin", inlineReplacement(el));
695
+ el.remove();
696
+ }
697
+ return true;
698
+ }
699
+ /**
700
+ * 本文HTMLに保持された複雑表(colspan/rowspan・多段ヘッダ)をその場で正規化し、あわせて全表の
701
+ * セル内ブロック要素をインライン化する。
702
+ * 救出経路(reinsertDroppedTables)は「Readabilityが落とした表」しか正規化しないため、
703
+ * Readability/アダプタ/ジオメトリ/selector等が本文に保持した複雑表は未正規化のまま
704
+ * turndownへ渡ると列ズレ・空セルになる。turndown直前の全経路共通パスでこれを補う。
705
+ * (アダプタ経路(例: zenn等)はReadabilityを経由しないため、readability経路限定ではなく
706
+ * 全経路に適用する必要がある。)
707
+ * 正規化はspan/多段ヘッダを持つ最外データ表だけを対象にし、レイアウト表・単純表・
708
+ * 既に正規化済みの表(救出で挿入済みを含む)は触らないため冪等。
709
+ * インライン化はブロック要素を持つ全ての表が対象(セル内ul等によるturndown-gfmのouterHTML
710
+ * ダンプを防ぐため)。入れ子表もgfmからは単体の表として変換される(外表スキップ時に内表だけが
711
+ * GFM表になる)ので、最外表に限定せず各表を個別にインライン化する。正規化より先に全表を
712
+ * インライン化するのは、外表の正規化(remove+再挿入)後では旧サブツリー内の入れ子表への
713
+ * 変更が出力へ反映されないため。
714
+ */
715
+ export function normalizeRetainedTables(contentHtml) {
716
+ if (!contentHtml.includes("<table"))
717
+ return contentHtml;
718
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${contentHtml}</body></html>`);
719
+ const host = document;
720
+ const tables = Array.from(host.body.querySelectorAll("table"));
721
+ let changed = false;
722
+ for (const table of tables) {
723
+ if (inlineCellBlocks(table))
724
+ changed = true;
725
+ }
726
+ for (const table of tables) {
727
+ if (table.parentElement?.closest("table"))
728
+ continue; // 入れ子表は最外表側で処理する。
729
+ if (!needsTableNormalization(table))
730
+ continue;
731
+ table.insertAdjacentHTML("afterend", normalizeTableHtml(table.outerHTML));
732
+ table.remove();
733
+ changed = true;
734
+ }
735
+ return changed ? host.body.innerHTML : contentHtml;
736
+ }
737
+ /**
738
+ * turndownにはstyle/scriptの内容を除外するルールが無く、DOM上に残っていると
739
+ * 地の文への吸収(defaultReplacementが子孫テキストをそのまま連結する)か、gfmプラグインの
740
+ * 複雑表フォールバック(node.outerHTMLをそのままダンプする)経由でMarkdownへ混入する。
741
+ * Readability自身は_prepDocumentで<style>を除去するが、それはparse()呼び出し時点の
742
+ * ドキュメントにしか効かない: 救出表(collectDataTables)はparse前のouterHTMLを採取するため
743
+ * 道連れにしてしまい、アダプタ/selector経路はReadabilityを経由すらしない。抽出方式に関わらず
744
+ * 確実に除去するため、全ての抽出処理より前にドキュメント全体から取り除く
745
+ * (removeConsentBannersより前: バナー判定のテキスト長ヒューリスティックが埋め込みCSSで
746
+ * 水増しされるのも防げる)。pre/code内は著者が意図的に置いたコード例とみなし対象外にする。
747
+ *
748
+ * noscriptは対象に含めない: Readability.parse()は内部の_unwrapNoscriptImages()で、遅延読み込み
749
+ * プレースホルダimgをnoscript内の実画像へ差し替えてからnoscriptを除去する(style/scriptには
750
+ * 無い復元ロジック)。ここで先回りして除去すると差し替え元のnoscriptが消え、劣化した
751
+ * プレースホルダ画像しか残らなくなる。noscript自体の除去はremoveResidualNoscriptElements
752
+ * (turndown直前の全経路共通パス、Readability実行後)に委ねる。
753
+ */
754
+ export function removeStyleLeakElements(document) {
755
+ for (const el of Array.from(document.querySelectorAll("style, script"))) {
756
+ if (el.closest("pre, code"))
757
+ continue;
758
+ el.remove();
759
+ }
760
+ }
761
+ /**
762
+ * <noscript>の除去は、Readability.parse()内の_unwrapNoscriptImages()(遅延読み込み
763
+ * プレースホルダimgをnoscript内の実画像へ差し替える処理)より後に行う必要がある
764
+ * (removeStyleLeakElementsのコメント参照)。readability/geometry/body-fallback経路は
765
+ * Readability.parse()が常に呼ばれる(結果を使わない場合も含め、渡したdocumentへ破壊的に
766
+ * 適用される)ため、この時点で画像差し替え・noscript除去は既に完了しており本関数は素通りする。
767
+ * 一方adapter/selector経路はReadabilityを経由しないため<noscript>が残ったままで、
768
+ * 地の文へ混入する。turndown直前の全経路共通パスに置くことで、経由済みの経路には影響を
769
+ * 与えず(冪等)、未経由の経路だけを確実にカバーする。pre/code内は著者が意図的に置いた
770
+ * コード例とみなし対象外にする。
771
+ */
772
+ export function removeResidualNoscriptElements(html) {
773
+ if (!/<noscript[\s>]/i.test(html))
774
+ return html;
775
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
776
+ const host = document;
777
+ let changed = false;
778
+ for (const el of Array.from(host.body.querySelectorAll("noscript"))) {
779
+ if (el.closest("pre, code"))
780
+ continue;
781
+ el.remove();
782
+ changed = true;
783
+ }
784
+ return changed ? host.body.innerHTML : html;
785
+ }
315
786
  export function extractMarkdown(html, options = {}) {
316
787
  const url = options.url ?? "about:blank";
317
788
  const { document } = parseHTML(html);
789
+ removeStyleLeakElements(document);
318
790
  stripRubyAnnotations(document);
319
791
  removeConsentBanners(document);
320
792
  // レンダリング可視テキストに近い統計を、プルーニング/selector適用前の元ドキュメントから採取する
@@ -358,14 +830,22 @@ export function extractMarkdown(html, options = {}) {
358
830
  if (document.body) {
359
831
  prunedBlockCount = pruneLowValueBlocks(document.body);
360
832
  }
361
- // Readabilityはリンク密度の高いデータ表(各国人口ソート表等)を本文から丸ごと落とすことがある。
362
- // parseは渡したdocumentを破壊的に変更するため、parse前に救出候補の表を位置アンカー付きで採取しておく。
833
+ // Readabilityはリンク密度の高いデータ表(各国人口ソート表等)を本文から丸ごと落とし、
834
+ // 見出しをラッパー要素ごと剥ぐこともある。parseは渡したdocumentを破壊的に変更するため、
835
+ // parse前に救出候補の表・見出しを位置アンカー付きで採取しておく。
363
836
  const dataTables = document.body ? collectDataTables(document.body) : [];
837
+ const droppedHeadings = document.body ? collectHeadings(document.body) : [];
364
838
  const article = new Readability(document).parse();
365
839
  const geometryHtml = options.geometry ? extractByGeometry(document, options.geometry) : null;
366
840
  if (article && article.content && (article.textContent ?? "").trim().length >= MIN_READABILITY_TEXT_LENGTH) {
367
- // Readabilityが取りこぼしたデータ表を、可能なら元の位置へ戻して本文へ再結合する。
368
- contentHtml = reinsertDroppedTables(article.content, dataTables).html;
841
+ // 見出し→表→見出しの順で戻す。1回目の見出し復元で表のアンカー(表直前の見出し優先)を
842
+ // 立ててから表を戻す。表が1つでも戻ったときだけ2回目を回し、Readabilityが落として救出で
843
+ // 本文へ戻った表をアンカーに持つ見出し(データ表のみの節)を拾う(1回目は表が本文に無く
844
+ // 復元できない)。表が戻らなければcontentは不変で2回目は必ずno-opなので、一般ページに
845
+ // 常時パースコストを乗せないよう分岐する。reinsertDroppedHeadingsは既存見出し集合で冪等。
846
+ const withHeadings = reinsertDroppedHeadings(article.content, droppedHeadings).html;
847
+ const tableStep = reinsertDroppedTables(withHeadings, dataTables);
848
+ contentHtml = tableStep.appended > 0 ? reinsertDroppedHeadings(tableStep.html, droppedHeadings).html : tableStep.html;
369
849
  title = article.title || title;
370
850
  extractionMethod = "readability";
371
851
  }
@@ -385,8 +865,13 @@ export function extractMarkdown(html, options = {}) {
385
865
  }
386
866
  }
387
867
  }
868
+ // turndownは複雑表(colspan/rowspan・多段ヘッダ)を列ズレ・空セルにするため、変換前に
869
+ // 全経路共通で最外データ表を正規化する(救出経路で正規化済みの表・単純表・レイアウト表は素通し)。
870
+ const normalizedHtml = normalizeRetainedTables(contentHtml);
871
+ // adapter/selector経路(Readability非経由)に残り得るnoscriptを最終段で除去する。詳細はコメント参照。
872
+ const sanitizedHtml = removeResidualNoscriptElements(normalizedHtml);
388
873
  const turndown = createTurndownService();
389
- const rawMarkdown = turndown.turndown(contentHtml).trim();
874
+ const rawMarkdown = turndown.turndown(sanitizedHtml).trim();
390
875
  const markdown = normalizeCjkText(rawMarkdown);
391
876
  return {
392
877
  title,