amenbo 0.3.0 → 0.4.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.
@@ -25,6 +25,7 @@ export interface ExtractResult {
25
25
  interface TableCandidate {
26
26
  tagName: string;
27
27
  outerHTML: string;
28
+ innerHTML: string;
28
29
  textContent: string;
29
30
  parentElement: TableCandidate | null;
30
31
  closest(selector: string): TableCandidate | null;
@@ -37,15 +38,31 @@ interface TableQueryHost {
37
38
  export interface DroppedTable {
38
39
  /** turndownへ渡す正規化済みHTML(colspan/rowspan展開・多段ヘッダ結合後)。 */
39
40
  html: string;
40
- /** 元の表テキスト先頭。Readability出力に既にある表かの重複判定に使う(正規化前基準)。 */
41
- signature: string;
41
+ /**
42
+ * 表内セル(th/td)から作った照合プローブ群。Readability出力に既にある表かの重複判定に使う。
43
+ * 単一signatureではなく複数プローブの多数決にするのは、Readabilityが表を保持しつつ一部セルの
44
+ * 隠し要素を刈る(Langエラーspan・隠し座標span等)ため、先頭固定のsignatureだと保持済みの表でも
45
+ * 照合が外れて丸ごと再挿入されるのを防ぐため。contentHtml側と同じnormalizeForSignature
46
+ * (タグ→空白)で両辺を揃える(空白非対称による重複挿入対策)。
47
+ */
48
+ probes: string[];
42
49
  /** 表の直前の見出し(優先)または近傍段落の正規化テキスト。位置復元のアンカー。無ければ空。 */
43
50
  anchor: string;
44
51
  }
45
52
  /**
46
53
  * turndownのGFMテーブル変換はcolspan/rowspan・多段ヘッダを扱えず列ズレや空セルを生む。
47
- * グリッドを再構築してspanを実セルへ展開し、先頭の連続ヘッダ行は1行へ結合してから
48
- * 全行同一列数の単純表を返す。救出経路の表のみに適用する(影響を局所化)。
54
+ * グリッドを再構築してspanを「位置の占有」として展開し(内容の複製はしない)、先頭の連続ヘッダ行は
55
+ * 1行へ結合してから全行同一列数の単純表を返す。救出経路(collectDataTables)とReadability/アダプタ等が
56
+ * 本文に保持した複雑表(normalizeRetainedTables)の双方から呼ばれる。
57
+ * spanの無い単純表・既に正規化済みの表に対しては構造を変えず冪等に振る舞う。
58
+ * captionは再構築で失われないよう先頭へ復元する。
59
+ *
60
+ * 内容配置は行の役割で分岐する:
61
+ * - 列ヘッダ行(all-th ∧ 起点セル2つ以上): 占有読み。colspanグループラベルは配下の各列へ複製される
62
+ * (多段ヘッダ結合「グループ 列名」の意味を保つための唯一の複製)
63
+ * - データ行: 起点位置にのみ内容、span継続位置は空セル(複製しない)
64
+ * - 全幅単一th行: 列ヘッダではなく表題/節ラベル。先頭の連続分はcaptionへ畳み(turndown-gfmが
65
+ * ヘッダ行を見失って複雑表フォールバックへ落ちるのを防ぐ)、表中間の区切り行はデータ行として出力
49
66
  */
50
67
  export declare function normalizeTableHtml(tableHtml: string): string;
51
68
  /**
@@ -64,5 +81,60 @@ export declare function reinsertDroppedTables(contentHtml: string, tables: Dropp
64
81
  html: string;
65
82
  appended: number;
66
83
  };
84
+ /**
85
+ * 本文HTMLに保持された複雑表(colspan/rowspan・多段ヘッダ)をその場で正規化し、あわせて全表の
86
+ * セル内ブロック要素をインライン化する。
87
+ * 救出経路(reinsertDroppedTables)は「Readabilityが落とした表」しか正規化しないため、
88
+ * Readability/アダプタ/ジオメトリ/selector等が本文に保持した複雑表は未正規化のまま
89
+ * turndownへ渡ると列ズレ・空セルになる。turndown直前の全経路共通パスでこれを補う。
90
+ * (検証URLの日本語Wikipedia rowspan表はアダプタ経路=Readability非経由のため、
91
+ * readability経路限定ではなく全経路に適用する必要がある。)
92
+ * 正規化はspan/多段ヘッダを持つ最外データ表だけを対象にし、レイアウト表・単純表・
93
+ * 既に正規化済みの表(救出で挿入済みを含む)は触らないため冪等。
94
+ * インライン化はブロック要素を持つ全ての表が対象(セル内ul等によるturndown-gfmのouterHTML
95
+ * ダンプを防ぐため)。入れ子表もgfmからは単体の表として変換される(外表スキップ時に内表だけが
96
+ * GFM表になる)ので、最外表に限定せず各表を個別にインライン化する。正規化より先に全表を
97
+ * インライン化するのは、外表の正規化(remove+再挿入)後では旧サブツリー内の入れ子表への
98
+ * 変更が出力へ反映されないため。
99
+ */
100
+ export declare function normalizeRetainedTables(contentHtml: string): string;
101
+ /** removeStyleLeakElementsが除去対象を検索・除去できる最小限のDOM互換インターフェース。 */
102
+ interface StyleLeakElement {
103
+ closest(selector: string): unknown;
104
+ remove(): void;
105
+ }
106
+ export interface StyleLeakHostDocument {
107
+ querySelectorAll(selector: string): ArrayLike<StyleLeakElement>;
108
+ }
109
+ /**
110
+ * turndownにはstyle/scriptの内容を除外するルールが無く、DOM上に残っていると
111
+ * 地の文への吸収(defaultReplacementが子孫テキストをそのまま連結する)か、gfmプラグインの
112
+ * 複雑表フォールバック(node.outerHTMLをそのままダンプする)経由でMarkdownへ混入する。
113
+ * Readability自身は_prepDocumentで<style>を除去するが、それはparse()呼び出し時点の
114
+ * ドキュメントにしか効かない: 救出表(collectDataTables)はparse前のouterHTMLを採取するため
115
+ * 道連れにしてしまい、アダプタ/selector経路はReadabilityを経由すらしない。抽出方式に関わらず
116
+ * 確実に除去するため、全ての抽出処理より前にドキュメント全体から取り除く
117
+ * (removeConsentBannersより前: バナー判定のテキスト長ヒューリスティックが埋め込みCSSで
118
+ * 水増しされるのも防げる)。pre/code内は著者が意図的に置いたコード例とみなし対象外にする。
119
+ *
120
+ * noscriptは対象に含めない: Readability.parse()は内部の_unwrapNoscriptImages()で、遅延読み込み
121
+ * プレースホルダimgをnoscript内の実画像へ差し替えてからnoscriptを除去する(style/scriptには
122
+ * 無い復元ロジック)。ここで先回りして除去すると差し替え元のnoscriptが消え、劣化した
123
+ * プレースホルダ画像しか残らなくなる。noscript自体の除去はremoveResidualNoscriptElements
124
+ * (turndown直前の全経路共通パス、Readability実行後)に委ねる。
125
+ */
126
+ export declare function removeStyleLeakElements(document: StyleLeakHostDocument): void;
127
+ /**
128
+ * <noscript>の除去は、Readability.parse()内の_unwrapNoscriptImages()(遅延読み込み
129
+ * プレースホルダimgをnoscript内の実画像へ差し替える処理)より後に行う必要がある
130
+ * (removeStyleLeakElementsのコメント参照)。readability/geometry/body-fallback経路は
131
+ * Readability.parse()が常に呼ばれる(結果を使わない場合も含め、渡したdocumentへ破壊的に
132
+ * 適用される)ため、この時点で画像差し替え・noscript除去は既に完了しており本関数は素通りする。
133
+ * 一方adapter/selector経路はReadabilityを経由しないため<noscript>が残ったままで、
134
+ * 地の文へ混入する。turndown直前の全経路共通パスに置くことで、経由済みの経路には影響を
135
+ * 与えず(冪等)、未経由の経路だけを確実にカバーする。pre/code内は著者が意図的に置いた
136
+ * コード例とみなし対象外にする。
137
+ */
138
+ export declare function removeResidualNoscriptElements(html: string): string;
67
139
  export declare function extractMarkdown(html: string, options?: ExtractOptions): ExtractResult;
68
140
  export {};
@@ -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
  }
@@ -244,6 +321,32 @@ function findTableAnchor(seq, tableIndex) {
244
321
  }
245
322
  return paragraph;
246
323
  }
324
+ /**
325
+ * 表内セル(th/td)から重複判定用の照合プローブを作る。入れ子表を内包するセルは、その内側の
326
+ * 表全体がinnerHTMLに入り込みノイズになる(かつ内側セルは個別に拾える)ため除外する。
327
+ * 長さ8字以上のセルテキストを重複除去し、長い順(安定ソート)に最大3本、各60字へ切り詰める。
328
+ * 対象セルが1つも無い表は、従来のouterHTML先頭を単一プローブとしてフォールバックする。
329
+ */
330
+ function buildTableProbes(table) {
331
+ const cells = Array.from(table.querySelectorAll("th, td")).filter((cell) => cell.querySelectorAll("table").length === 0);
332
+ const seen = new Set();
333
+ const probes = [];
334
+ for (const cell of cells) {
335
+ const text = normalizeForSignature(cell.innerHTML);
336
+ if (text.length < PROBE_MIN_LENGTH || seen.has(text))
337
+ continue;
338
+ seen.add(text);
339
+ probes.push(text);
340
+ }
341
+ if (probes.length === 0) {
342
+ return [normalizeForSignature(table.outerHTML).slice(0, PROBE_FALLBACK_MAX_LENGTH)].filter((p) => p.length > 0);
343
+ }
344
+ return probes
345
+ .map((probe, index) => ({ probe, index }))
346
+ .sort((a, b) => b.probe.length - a.probe.length || a.index - b.index)
347
+ .slice(0, PROBE_MAX_COUNT)
348
+ .map(({ probe }) => probe.slice(0, PROBE_MAX_LENGTH));
349
+ }
247
350
  /**
248
351
  * Readabilityが取りこぼしがちなデータ表を、救出候補としてHTML文字列+位置アンカーで採取する。
249
352
  * Readabilityのparseは渡したdocumentを破壊的に変更するため、必ずparse前に呼ぶこと。
@@ -273,13 +376,28 @@ export function collectDataTables(body) {
273
376
  continue;
274
377
  result.push({
275
378
  html: normalizeTableHtml(table.outerHTML),
276
- // 重複判定は正規化前の元テキスト基準にする(Readability保持時の照合を安定させる)。
277
- signature: normalizeForSignature(table.textContent).slice(0, ANCHOR_MAX_LENGTH),
379
+ probes: buildTableProbes(table),
278
380
  anchor: findTableAnchor(seq, i),
279
381
  });
280
382
  }
281
383
  return result;
282
384
  }
385
+ /**
386
+ * 表のプローブの真の過半数(floor(n/2)+1)が本文に含まれていれば「既に本文にある」と判定する
387
+ * (プローブ0本は既存扱い)。ceil(n/2)だとn=2のとき1本一致(50%)で既存と判定され、本文の地の文と
388
+ * 偶然一致した1本のせいで本当に落ちた表が救出されない=サイレント欠落に倒れる。重複挿入(可視・
389
+ * 有界)よりデータ欠落(不可視)の方が害が大きいため、偶数本では厳しい側に倒す。
390
+ */
391
+ function isTableAlreadyPresent(table, contentText) {
392
+ if (table.probes.length === 0)
393
+ return true;
394
+ let hits = 0;
395
+ for (const probe of table.probes) {
396
+ if (probe.length > 0 && contentText.includes(probe))
397
+ hits++;
398
+ }
399
+ return hits >= Math.floor(table.probes.length / 2) + 1;
400
+ }
283
401
  /**
284
402
  * Readability出力に含まれない表だけを、可能なら元の位置(アンカー直後)へ、無理なら末尾へ再結合する。
285
403
  * アンカー(表の直前見出し/段落)がReadability出力に残っていれば、その要素の直後に挿入して
@@ -288,9 +406,10 @@ export function collectDataTables(body) {
288
406
  export function reinsertDroppedTables(contentHtml, tables) {
289
407
  if (tables.length === 0)
290
408
  return { html: contentHtml, appended: 0 };
291
- // Readabilityが既に保持している表はシグネチャ(元テキスト先頭)で除外する。
409
+ // Readabilityが既に保持している表はプローブ多数決で除外する。Readabilityが一部セルを刈っても
410
+ // 過半数のプローブが本文に残っていれば「既にある」と判定する。
292
411
  const contentText = normalizeForSignature(contentHtml);
293
- const missing = tables.filter((table) => table.signature.length > 0 && !contentText.includes(table.signature));
412
+ const missing = tables.filter((table) => !isTableAlreadyPresent(table, contentText));
294
413
  if (missing.length === 0)
295
414
  return { html: contentHtml, appended: 0 };
296
415
  const { document } = parseHTML(`<!DOCTYPE html><html><body>${contentHtml}</body></html>`);
@@ -312,9 +431,204 @@ export function reinsertDroppedTables(contentHtml, tables) {
312
431
  const html = tail.length > 0 ? `${inserted}\n${tail.join("\n")}` : inserted;
313
432
  return { html, appended: missing.length };
314
433
  }
434
+ /**
435
+ * 正規化が必要な「データ表」かを判定する。2行2列以上かつ、colspan/rowspanを持つか
436
+ * 「先頭に2行以上の連続ヘッダ行があり、かつその後にデータ行が残る(多段ヘッダ)」表のみtrue。
437
+ * 単純表・レイアウト表・全行がヘッダの表は、turndownに任せて差分と誤破壊を避けるため弾く
438
+ * (全ヘッダ表を多段ヘッダとして畳むとデータ行が消えるため対象外)。
439
+ * 連続ヘッダ行の数え方はnormalizeTableHtmlの列ヘッダ定義(all-th ∧ セル2つ以上)に揃える
440
+ * (全幅単一th行を列ヘッダに数えると単一セルラベルが誤って多段ヘッダ扱いされる)。
441
+ */
442
+ function needsTableNormalization(table) {
443
+ const rows = Array.from(table.querySelectorAll("tr")).filter((tr) => tr.closest("table") === table);
444
+ if (rows.length < 2)
445
+ return false;
446
+ let maxCells = 0;
447
+ let hasSpan = false;
448
+ let leadingHeaderRows = 0;
449
+ let inLeadingHeaders = true;
450
+ for (const row of rows) {
451
+ const cells = Array.from(row.children).filter((el) => el.tagName === "TD" || el.tagName === "TH");
452
+ if (cells.length > maxCells)
453
+ maxCells = cells.length;
454
+ for (const cell of cells) {
455
+ if (clampSpan(cell.getAttribute("colspan")) > 1 || clampSpan(cell.getAttribute("rowspan")) > 1)
456
+ hasSpan = true;
457
+ }
458
+ const isColumnHeader = cells.length >= 2 && cells.every((cell) => cell.tagName === "TH");
459
+ if (inLeadingHeaders && isColumnHeader)
460
+ leadingHeaderRows++;
461
+ else
462
+ inLeadingHeaders = false;
463
+ }
464
+ if (maxCells < 2)
465
+ return false;
466
+ const hasMultiHeaderWithData = leadingHeaderRows >= 2 && leadingHeaderRows < rows.length;
467
+ return hasSpan || hasMultiHeaderWithData;
468
+ }
469
+ const CELL_BLOCK_SELECTOR = "ul, ol, h1, h2, h3, h4, h5, h6, hr, blockquote, pre";
470
+ /** テキストをHTMLへ差し戻す際の最小エスケープ(pre→codeでtextContentを埋め込むため)。 */
471
+ function escapeHtml(text) {
472
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
473
+ }
474
+ /**
475
+ * 直前に非空白の内容があり、かつそれが<br>でない場合にtrue。インライン化した置換文字列が
476
+ * 先行テキストと無境界に連結される(例: 親liのテキストと入れ子リスト先頭項目が「親1子1」化する)
477
+ * のを防ぐため、置換文字列の先頭に<br>を足すべきかの判定に使う。
478
+ */
479
+ function needsLeadingBreak(el) {
480
+ let prev = el.previousSibling;
481
+ while (prev) {
482
+ if (prev.nodeType === 1)
483
+ return prev.tagName !== "BR";
484
+ if (prev.nodeType === 3 && (prev.textContent ?? "").trim().length > 0)
485
+ return true;
486
+ prev = prev.previousSibling;
487
+ }
488
+ return false;
489
+ }
490
+ /** ブロック要素をGFM表セルに収まるインラインHTMLへ変換する。 */
491
+ function inlineReplacement(el) {
492
+ const tag = el.tagName;
493
+ // hr(それ自体が区切り)とblockquote(ブロック子要素を保持したままのunwrap)以外は純インラインに
494
+ // なるため、先行内容との境界を<br>で明示する。
495
+ const lead = tag !== "HR" && tag !== "BLOCKQUOTE" && needsLeadingBreak(el) ? "<br>" : "";
496
+ if (tag === "UL" || tag === "OL") {
497
+ // マーカー無しで各liを<br>連結する(番号・入れ子構造は落とし、テキストとリンクを残す)。
498
+ const items = Array.from(el.children)
499
+ .filter((child) => child.tagName === "LI")
500
+ .map((li) => li.innerHTML.trim())
501
+ .filter((html) => html.length > 0)
502
+ .join("<br>");
503
+ return lead + items;
504
+ }
505
+ if (/^H[1-6]$/.test(tag))
506
+ return `${lead}<strong>${el.innerHTML.trim()}</strong><br>`;
507
+ if (tag === "HR")
508
+ return "<br>";
509
+ if (tag === "BLOCKQUOTE")
510
+ return el.innerHTML; // unwrap(子を保持)。
511
+ return `${lead}<code>${escapeHtml(el.textContent.replace(/\s*\n\s*/g, " ").trim())}</code>`; // PRE。
512
+ }
513
+ /** rootからの深さ(祖先数)を返す。入れ子リスト等を深い方から平坦化する順序付けに使う。 */
514
+ function depthWithin(el, root) {
515
+ let depth = 0;
516
+ let cur = el.parentElement;
517
+ while (cur && cur !== root) {
518
+ depth++;
519
+ cur = cur.parentElement;
520
+ }
521
+ return depth;
522
+ }
523
+ /**
524
+ * 表のセル内ブロック要素(ul/ol/h1-6/hr/blockquote/pre)をインライン化する。
525
+ * turndown-gfmはこれらを含む表をtableShouldBeHtml判定でouterHTMLごとダンプする(joplin-table-wrapper
526
+ * のHTMLリーク)ため、プラグインを触らず入力側で発動条件を消す。入れ子表そのものは触らず、
527
+ * 入れ子表内の要素はその入れ子表を処理する呼び出しに委ねる(closestが自表と一致する要素のみ対象)。
528
+ * 入れ子リストは深い方から平坦化する。
529
+ */
530
+ function inlineCellBlocks(table) {
531
+ const targets = Array.from(table.querySelectorAll(CELL_BLOCK_SELECTOR)).filter((el) => el.closest("table") === table);
532
+ if (targets.length === 0)
533
+ return false;
534
+ targets.sort((a, b) => depthWithin(b, table) - depthWithin(a, table));
535
+ for (const el of targets) {
536
+ el.insertAdjacentHTML("beforebegin", inlineReplacement(el));
537
+ el.remove();
538
+ }
539
+ return true;
540
+ }
541
+ /**
542
+ * 本文HTMLに保持された複雑表(colspan/rowspan・多段ヘッダ)をその場で正規化し、あわせて全表の
543
+ * セル内ブロック要素をインライン化する。
544
+ * 救出経路(reinsertDroppedTables)は「Readabilityが落とした表」しか正規化しないため、
545
+ * Readability/アダプタ/ジオメトリ/selector等が本文に保持した複雑表は未正規化のまま
546
+ * turndownへ渡ると列ズレ・空セルになる。turndown直前の全経路共通パスでこれを補う。
547
+ * (検証URLの日本語Wikipedia rowspan表はアダプタ経路=Readability非経由のため、
548
+ * readability経路限定ではなく全経路に適用する必要がある。)
549
+ * 正規化はspan/多段ヘッダを持つ最外データ表だけを対象にし、レイアウト表・単純表・
550
+ * 既に正規化済みの表(救出で挿入済みを含む)は触らないため冪等。
551
+ * インライン化はブロック要素を持つ全ての表が対象(セル内ul等によるturndown-gfmのouterHTML
552
+ * ダンプを防ぐため)。入れ子表もgfmからは単体の表として変換される(外表スキップ時に内表だけが
553
+ * GFM表になる)ので、最外表に限定せず各表を個別にインライン化する。正規化より先に全表を
554
+ * インライン化するのは、外表の正規化(remove+再挿入)後では旧サブツリー内の入れ子表への
555
+ * 変更が出力へ反映されないため。
556
+ */
557
+ export function normalizeRetainedTables(contentHtml) {
558
+ if (!contentHtml.includes("<table"))
559
+ return contentHtml;
560
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${contentHtml}</body></html>`);
561
+ const host = document;
562
+ const tables = Array.from(host.body.querySelectorAll("table"));
563
+ let changed = false;
564
+ for (const table of tables) {
565
+ if (inlineCellBlocks(table))
566
+ changed = true;
567
+ }
568
+ for (const table of tables) {
569
+ if (table.parentElement?.closest("table"))
570
+ continue; // 入れ子表は最外表側で処理する。
571
+ if (!needsTableNormalization(table))
572
+ continue;
573
+ table.insertAdjacentHTML("afterend", normalizeTableHtml(table.outerHTML));
574
+ table.remove();
575
+ changed = true;
576
+ }
577
+ return changed ? host.body.innerHTML : contentHtml;
578
+ }
579
+ /**
580
+ * turndownにはstyle/scriptの内容を除外するルールが無く、DOM上に残っていると
581
+ * 地の文への吸収(defaultReplacementが子孫テキストをそのまま連結する)か、gfmプラグインの
582
+ * 複雑表フォールバック(node.outerHTMLをそのままダンプする)経由でMarkdownへ混入する。
583
+ * Readability自身は_prepDocumentで<style>を除去するが、それはparse()呼び出し時点の
584
+ * ドキュメントにしか効かない: 救出表(collectDataTables)はparse前のouterHTMLを採取するため
585
+ * 道連れにしてしまい、アダプタ/selector経路はReadabilityを経由すらしない。抽出方式に関わらず
586
+ * 確実に除去するため、全ての抽出処理より前にドキュメント全体から取り除く
587
+ * (removeConsentBannersより前: バナー判定のテキスト長ヒューリスティックが埋め込みCSSで
588
+ * 水増しされるのも防げる)。pre/code内は著者が意図的に置いたコード例とみなし対象外にする。
589
+ *
590
+ * noscriptは対象に含めない: Readability.parse()は内部の_unwrapNoscriptImages()で、遅延読み込み
591
+ * プレースホルダimgをnoscript内の実画像へ差し替えてからnoscriptを除去する(style/scriptには
592
+ * 無い復元ロジック)。ここで先回りして除去すると差し替え元のnoscriptが消え、劣化した
593
+ * プレースホルダ画像しか残らなくなる。noscript自体の除去はremoveResidualNoscriptElements
594
+ * (turndown直前の全経路共通パス、Readability実行後)に委ねる。
595
+ */
596
+ export function removeStyleLeakElements(document) {
597
+ for (const el of Array.from(document.querySelectorAll("style, script"))) {
598
+ if (el.closest("pre, code"))
599
+ continue;
600
+ el.remove();
601
+ }
602
+ }
603
+ /**
604
+ * <noscript>の除去は、Readability.parse()内の_unwrapNoscriptImages()(遅延読み込み
605
+ * プレースホルダimgをnoscript内の実画像へ差し替える処理)より後に行う必要がある
606
+ * (removeStyleLeakElementsのコメント参照)。readability/geometry/body-fallback経路は
607
+ * Readability.parse()が常に呼ばれる(結果を使わない場合も含め、渡したdocumentへ破壊的に
608
+ * 適用される)ため、この時点で画像差し替え・noscript除去は既に完了しており本関数は素通りする。
609
+ * 一方adapter/selector経路はReadabilityを経由しないため<noscript>が残ったままで、
610
+ * 地の文へ混入する。turndown直前の全経路共通パスに置くことで、経由済みの経路には影響を
611
+ * 与えず(冪等)、未経由の経路だけを確実にカバーする。pre/code内は著者が意図的に置いた
612
+ * コード例とみなし対象外にする。
613
+ */
614
+ export function removeResidualNoscriptElements(html) {
615
+ if (!/<noscript[\s>]/i.test(html))
616
+ return html;
617
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
618
+ const host = document;
619
+ let changed = false;
620
+ for (const el of Array.from(host.body.querySelectorAll("noscript"))) {
621
+ if (el.closest("pre, code"))
622
+ continue;
623
+ el.remove();
624
+ changed = true;
625
+ }
626
+ return changed ? host.body.innerHTML : html;
627
+ }
315
628
  export function extractMarkdown(html, options = {}) {
316
629
  const url = options.url ?? "about:blank";
317
630
  const { document } = parseHTML(html);
631
+ removeStyleLeakElements(document);
318
632
  stripRubyAnnotations(document);
319
633
  removeConsentBanners(document);
320
634
  // レンダリング可視テキストに近い統計を、プルーニング/selector適用前の元ドキュメントから採取する
@@ -385,8 +699,13 @@ export function extractMarkdown(html, options = {}) {
385
699
  }
386
700
  }
387
701
  }
702
+ // turndownは複雑表(colspan/rowspan・多段ヘッダ)を列ズレ・空セルにするため、変換前に
703
+ // 全経路共通で最外データ表を正規化する(救出経路で正規化済みの表・単純表・レイアウト表は素通し)。
704
+ const normalizedHtml = normalizeRetainedTables(contentHtml);
705
+ // adapter/selector経路(Readability非経由)に残り得るnoscriptを最終段で除去する。詳細はコメント参照。
706
+ const sanitizedHtml = removeResidualNoscriptElements(normalizedHtml);
388
707
  const turndown = createTurndownService();
389
- const rawMarkdown = turndown.turndown(contentHtml).trim();
708
+ const rawMarkdown = turndown.turndown(sanitizedHtml).trim();
390
709
  const markdown = normalizeCjkText(rawMarkdown);
391
710
  return {
392
711
  title,