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.
- package/README.en.md +3 -2
- package/README.md +3 -2
- package/dist/adapters/index.d.ts +3 -1
- package/dist/adapters/index.js +3 -3
- package/dist/adapters/index.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +21 -33
- package/dist/core.js +4 -201
- package/dist/core.js.map +1 -1
- package/dist/extract/markdown.d.ts +76 -4
- package/dist/extract/markdown.js +347 -28
- package/dist/extract/markdown.js.map +1 -1
- package/dist/fetcher/http.d.ts +1 -1
- package/dist/fetcher/http.js +1 -1
- package/dist/formatting.d.ts +48 -0
- package/dist/formatting.js +203 -0
- package/dist/formatting.js.map +1 -0
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
41
|
-
|
|
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
|
|
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 {};
|
package/dist/extract/markdown.js
CHANGED
|
@@ -75,7 +75,9 @@ function collectQualityInput(document, geometry) {
|
|
|
75
75
|
extractedTokenEstimate: 0,
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
248
|
+
while (start + headerCount < rowCount && isColumnHeaderRow(start + headerCount))
|
|
190
249
|
headerCount++;
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (headerCount >= 2) {
|
|
197
|
-
// 多段ヘッダ:
|
|
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 =
|
|
202
|
-
const h =
|
|
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
|
-
|
|
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
|
|
215
|
-
for (let r =
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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(
|
|
708
|
+
const rawMarkdown = turndown.turndown(sanitizedHtml).trim();
|
|
390
709
|
const markdown = normalizeCjkText(rawMarkdown);
|
|
391
710
|
return {
|
|
392
711
|
title,
|