@helpfeel/cosense-cli 1.7.0 → 1.10.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.md CHANGED
@@ -6,6 +6,8 @@ Cosenseのページを読み・調べ・編集するAgent SkillとCLI
6
6
 
7
7
  ### for Claude Code
8
8
 
9
+ Claude Codeを起動し、インストールコマンドを実行する
10
+
9
11
  ```
10
12
  /plugin marketplace add helpfeel/cosense-cli
11
13
  ```
@@ -14,10 +16,20 @@ Cosenseのページを読み・調べ・編集するAgent SkillとCLI
14
16
  /plugin install cosense-cli@cosense-cli
15
17
  ```
16
18
 
19
+ Claude CodeはSkillの自動更新が設定可能です。 `/plugins` からMarketplaceを選択し、有効化してください。
20
+
17
21
  ### for Codex
18
22
 
23
+ Codexに入らず、ターミナルでインストールコマンドを実行する
24
+
25
+ ```
26
+ npx skills install helpfeel/cosense-cli --agent codex
27
+ ```
28
+
29
+ CodexはSkillの自動更新ができません。手動更新してください。
30
+
19
31
  ```
20
- $skill-installer https://github.com/helpfeel/cosense-cli
32
+ npx skills update cosense
21
33
  ```
22
34
 
23
35
  ## Install CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.7.0",
3
+ "version": "1.10.0",
4
4
  "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
5
5
  "homepage": "https://github.com/helpfeel/cosense-cli",
6
6
  "license": "MIT",
@@ -29,8 +29,8 @@
29
29
  "devDependencies": {
30
30
  "@types/node": "24.13.2",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.54.0",
33
- "oxlint": "1.70.0",
32
+ "oxfmt": "0.56.0",
33
+ "oxlint": "1.71.0",
34
34
  "typescript": "6.0.3"
35
35
  },
36
36
  "engines": {
@@ -1,4 +1,6 @@
1
1
  import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { applyFileMarkup, fetchFileMarkups } from '../lib/fileMarkup.ts';
3
+ import { renderLiterateDatabase } from '../lib/literateDatabase.ts';
2
4
  import { parsePageUrl } from '../lib/parseUrl.ts';
3
5
  import { fetchRelatedPages } from '../lib/relatedPages.ts';
4
6
  import {
@@ -54,13 +56,38 @@ Usage:
54
56
  0件の場合はセクションごと省略
55
57
 
56
58
  ## 本文
57
- 各行の text を改行で結合。fragment 指定行のみ末尾に #<lineId> を付与
59
+ 各行の text を改行で結合。fragment 指定行のみ末尾に #<lineId> を付与。
60
+ 本文中の、ページと同じhostのアップロードファイルURL (https://<host>/files/<fileId>[.<ext>])
61
+ は、囲むbracketごと1行の <cosense:file> タグに展開する。
62
+ type string? ファイルのContent-Type
63
+ url string 元のファイルURL
64
+ originalname string? アップロード時のファイル名
65
+ size number? ファイルのbyte数
66
+ description string? ファイルから抽出されたテキスト(画像のOCR、PDFの本文等)。
67
+ 先頭2000文字まで、超えた分は … に切り詰める。
68
+ 改行・"・バックスラッシュは \\n \\" \\\\ にエスケープ
69
+ 本文中のGyazo URL (https://gyazo.com/<hash> とその変種) も同様に
70
+ <cosense:gyazo> タグに展開する。
71
+ type string photo または video
72
+ url string 元のGyazo URL
73
+ thumbnail string curl等で直接取得できるプレビュー画像URL。photoは縮小版、
74
+ videoは動画から切り出された静止ポスター
75
+ image string? photoの原寸画像URL
76
+ width number? 原寸の横px
77
+ height number? 原寸の縦px
78
+ title string? Gyazoに設定されたタイトル
79
+ ファイル・Gyazoの情報を取得できなかった時(非公開・削除済み等)や、別host・query/hash
80
+ 付きのURLはタグ化されず、そのまま残る
58
81
 
59
82
  -------------------- Related Pages --------------------
60
83
  本文と関連ページ一覧の境界を示す非Markdown区切り線。Cosenseの#hashtag記法と
61
84
  衝突しないようにMarkdown見出しを避ける。
62
85
  ## 1 hop link
63
- このページの 1-hop 近傍ページタイトル一覧。 1-hop が 0 件なら区切り線ごと省略
86
+ このページの 1-hop 近傍ページタイトル一覧。 0 件なら区切り線ごと省略。
87
+ このページがinfobox定義ページ(本文に table:infobox または table:cosense を宣言している)の
88
+ 時は ## 1 hop link(Infoboxの文芸的データベース、TSV形式のテーブル) に変わる。表の構造は
89
+ cosense browseRelatedPages --help を参照。表に載らなかったページは
90
+ ## 1 hop link(表に載っていないページ) に出力する
64
91
 
65
92
  persistent: false の時:
66
93
  メタデータ・アイコン・テロメア・Infoboxは省略。 (このページはまだ作成されていません) と
@@ -119,6 +146,7 @@ interface PageData {
119
146
  lines?: PageLine[];
120
147
  icons?: string[];
121
148
  infoboxResult?: InfoboxResult[];
149
+ infoboxDefinition?: string[];
122
150
  }
123
151
 
124
152
  const LINE_ID_PATTERN = /^[0-9a-f]{24}$/;
@@ -268,11 +296,15 @@ interface BodyRender {
268
296
  matchedFragment: boolean;
269
297
  }
270
298
 
271
- const renderBody = (lines: PageLine[], fragment: string | null): BodyRender => {
299
+ const renderBody = (
300
+ lines: PageLine[],
301
+ fragment: string | null,
302
+ fileMarkups: Map<string, string>
303
+ ): BodyRender => {
272
304
  let matchedFragment = false;
273
305
  const out: string[] = [];
274
306
  for (const line of lines) {
275
- const text = normalizeIndent(line.text ?? '');
307
+ const text = applyFileMarkup(normalizeIndent(line.text ?? ''), fileMarkups);
276
308
  if (fragment && line.id === fragment) {
277
309
  matchedFragment = true;
278
310
  out.push(`${text}\t#${line.id}`);
@@ -283,11 +315,26 @@ const renderBody = (lines: PageLine[], fragment: string | null): BodyRender => {
283
315
  return { body: out.join('\n'), matchedFragment };
284
316
  };
285
317
 
286
- const renderRelatedPages = (hopValue: unknown): string | null => {
318
+ const renderRelatedPages = (
319
+ hopValue: unknown,
320
+ page: PageData
321
+ ): string | null => {
322
+ const sections: string[] = [];
323
+ // seen は文芸的データベースと 1 hop link で共有して、表に載ったページを再掲しないようにする
324
+ const seen = new Set<string>();
325
+ const literateDatabase = renderLiterateDatabase(page, hopValue, seen);
326
+ if (literateDatabase) sections.push(literateDatabase);
327
+
287
328
  const links1hop = (hopValue as { links1hop?: RelatedPage[] }).links1hop;
288
- const pages = dedupAndSortByPageRank(links1hop);
289
- if (pages.length === 0) return null;
290
- return `-------------------- Related Pages --------------------\n\n## 1 hop link\n\n${renderGroups(buildGroups(pages))}`;
329
+ const pages = dedupAndSortByPageRank(links1hop, seen);
330
+ if (pages.length > 0) {
331
+ const heading = literateDatabase
332
+ ? '## 1 hop link(表に載っていないページ)'
333
+ : '## 1 hop link';
334
+ sections.push(`${heading}\n\n${renderGroups(buildGroups(pages))}`);
335
+ }
336
+ if (sections.length === 0) return null;
337
+ return `-------------------- Related Pages --------------------\n\n${sections.join('\n\n')}`;
291
338
  };
292
339
 
293
340
  export const browsePage = async (args: string[]): Promise<void> => {
@@ -315,19 +362,26 @@ export const browsePage = async (args: string[]): Promise<void> => {
315
362
 
316
363
  if (!persistent) {
317
364
  sections.push('(このページはまだ作成されていません)');
318
- const { body } = renderBody(page.lines ?? [], null);
365
+ const { body } = renderBody(page.lines ?? [], null, new Map());
319
366
  sections.push(`## 本文(テンプレート)\n\n${body}`);
320
- const related = renderRelatedPages(hopValue);
367
+ const related = renderRelatedPages(hopValue, page);
321
368
  if (related) sections.push(related);
322
369
  process.stdout.write(`${sections.join('\n\n')}\n`);
323
370
  return;
324
371
  }
325
372
 
373
+ const fileMarkups = await fetchFileMarkups(
374
+ (page.lines ?? []).map(line => line.text ?? ''),
375
+ origin,
376
+ credential
377
+ );
378
+
326
379
  // 本文を先にrenderしてfragment一致状況を取得し、タイトル直後の説明文に反映する
327
380
  const validFragment = fragment !== null && LINE_ID_PATTERN.test(fragment);
328
381
  const { body, matchedFragment } = renderBody(
329
382
  page.lines ?? [],
330
- validFragment ? fragment : null
383
+ validFragment ? fragment : null,
384
+ fileMarkups
331
385
  );
332
386
  if (fragment !== null) {
333
387
  if (!validFragment) {
@@ -368,7 +422,7 @@ export const browsePage = async (args: string[]): Promise<void> => {
368
422
 
369
423
  sections.push(`## 本文\n\n${body}`);
370
424
 
371
- const related = renderRelatedPages(hopValue);
425
+ const related = renderRelatedPages(hopValue, page);
372
426
  if (related) sections.push(related);
373
427
 
374
428
  process.stdout.write(`${sections.join('\n\n')}\n`);
@@ -4,12 +4,19 @@ import {
4
4
  type Page,
5
5
  renderGroups
6
6
  } from '../lib/relatedPagesFormat.ts';
7
+ import {
8
+ type DefinitionPage,
9
+ renderLiterateDatabase
10
+ } from '../lib/literateDatabase.ts';
7
11
  import { fetchRelatedPages } from '../lib/relatedPages.ts';
12
+ import { parsePageUrl } from '../lib/parseUrl.ts';
13
+ import { requestJson } from '../lib/request.ts';
14
+ import { resolveCredential } from '../lib/settings.ts';
8
15
 
9
16
  export const browseRelatedPagesSummary =
10
- '1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する';
17
+ '1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する。infobox定義ページでは文芸的データベース(TSV表)を出力する';
11
18
 
12
- export const browseRelatedPagesHelp = `browseRelatedPages - 1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する
19
+ export const browseRelatedPagesHelp = `browseRelatedPages - 1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する。infobox定義ページでは文芸的データベース(TSV表)を出力する
13
20
 
14
21
  Usage:
15
22
  cosense browseRelatedPages <pageUrl>
@@ -32,6 +39,26 @@ Usage:
32
39
  ## 2 hop link
33
40
 
34
41
  - タイトル
42
+
43
+ 対象がinfobox定義ページ(本文に table:infobox または table:cosense を宣言している)の時:
44
+ Web UIと同様に、関連ページリストの先頭が文芸的データベース(TSV形式のテーブル)になる。
45
+ 行 = このページにリンクしているページ(pageRank 降順)。
46
+ 列 = Page / Created / Updated + 定義された項目。
47
+ セル = 各ページの本文からInfoboxが抜き出した値(Cosense記法のまま。セル内の改行は「 / 」に置換)。
48
+ 表に載らなかった関連ページは、続く ## 1 hop link(表に載っていないページ) に出力する。
49
+
50
+ # Related Pages
51
+
52
+ ## 1 hop link(Infoboxの文芸的データベース、TSV形式のテーブル)
53
+
54
+ Page/Created/Updated以外の列は、各ページの本文からInfoboxが抜き出した値
55
+
56
+ Page Created Updated 材料 カテゴリ
57
+ 麻婆豆腐 2018-03-04 2026-07-05 [豆腐]、[挽き肉] 中華
58
+
59
+ ## 1 hop link(表に載っていないページ)
60
+
61
+ - 料理
35
62
  `;
36
63
 
37
64
  export const browseRelatedPages = async (args: string[]): Promise<void> => {
@@ -39,8 +66,15 @@ export const browseRelatedPages = async (args: string[]): Promise<void> => {
39
66
  throw new Error('Usage: cosense browseRelatedPages <pageUrl>');
40
67
  }
41
68
  const [url] = args as [string];
69
+ const { origin, projectName, encodedTitle } = parsePageUrl(url);
70
+ const credential = resolveCredential(origin, projectName);
42
71
 
43
- const [result1hop, result2hop] = await Promise.allSettled([
72
+ // ページ本体はinfobox定義の取得にだけ使う。失敗しても従来のタイトル一覧に
73
+ // フォールバックできるよう、関連ページの取得失敗とは区別する
74
+ const [resultPage, result1hop, result2hop] = await Promise.allSettled([
75
+ requestJson(`${origin}/api/pages/v2/${projectName}/${encodedTitle}`, {
76
+ credential
77
+ }) as Promise<DefinitionPage>,
44
78
  fetchRelatedPages(url, 1),
45
79
  fetchRelatedPages(url, 2)
46
80
  ]);
@@ -49,8 +83,35 @@ export const browseRelatedPages = async (args: string[]): Promise<void> => {
49
83
  throw result1hop.reason;
50
84
  }
51
85
 
52
- // seen 1-hop と 2-hop で共有して、 1-hop に出たページが 2-hop にも再掲されないようにする
86
+ const sections: string[] = [];
87
+ // seen は文芸的データベース・1-hop・2-hopで共有して、先に出たページを再掲しないようにする
53
88
  const seen = new Set<string>();
89
+
90
+ let hasLiterateDatabase = false;
91
+ if (resultPage.status === 'rejected') {
92
+ // 非定義ページと区別が付かないまま黙って通常出力に落ちると、AIが不完全な
93
+ // 出力を信じてしまうので、判定不能である事をstderrで知らせる
94
+ process.stderr.write(
95
+ 'ページ情報の取得に失敗したため、infobox定義ページかどうか判定できません。文芸的データベースがあっても出力されません\n'
96
+ );
97
+ } else if (result1hop.status === 'fulfilled') {
98
+ const literateDatabase = renderLiterateDatabase(
99
+ resultPage.value,
100
+ result1hop.value,
101
+ seen
102
+ );
103
+ if (literateDatabase) {
104
+ sections.push(literateDatabase);
105
+ hasLiterateDatabase = true;
106
+ }
107
+ } else if ((resultPage.value.infoboxDefinition?.length ?? 0) > 0) {
108
+ // infobox定義ページだと確定しているのに関連ページの取得に失敗した場合も、
109
+ // 表が黙って欠落しないように知らせる
110
+ process.stderr.write(
111
+ '関連ページの取得に失敗したため、このinfobox定義ページの文芸的データベースを出力できません\n'
112
+ );
113
+ }
114
+
54
115
  const pages1hop =
55
116
  result1hop.status === 'fulfilled'
56
117
  ? dedupAndSortByPageRank(
@@ -66,9 +127,11 @@ export const browseRelatedPages = async (args: string[]): Promise<void> => {
66
127
  )
67
128
  : [];
68
129
 
69
- const sections: string[] = [];
70
130
  if (pages1hop.length > 0) {
71
- sections.push(`## 1 hop link\n\n${renderGroups(buildGroups(pages1hop))}`);
131
+ const heading = hasLiterateDatabase
132
+ ? '## 1 hop link(表に載っていないページ)'
133
+ : '## 1 hop link';
134
+ sections.push(`${heading}\n\n${renderGroups(buildGroups(pages1hop))}`);
72
135
  }
73
136
  if (pages2hop.length > 0) {
74
137
  sections.push(`## 2 hop link\n\n${renderGroups(buildGroups(pages2hop))}`);
@@ -0,0 +1,169 @@
1
+ import { requestJson } from './request.ts';
2
+ import type { Credential } from './settings.ts';
3
+
4
+ // 本文中のGCSアップロードファイルURL。末尾の (?![^\s[\]]) 境界で、直後に空白・bracket・
5
+ // 行末以外(?・#・余分なpath等)が続くURLは弾き、clean URLだけをマッチする。query/hash
6
+ // 付き等はマッチせず本文にそのまま残る。拡張子は .tar.gz 等の多重ドットも丸ごと取る
7
+ const FILE_URL_PATTERN =
8
+ /https?:\/\/[^\s[\]]+\/files\/([0-9a-f]{24})(?:\.[A-Za-z0-9]+)*(?![^\s[\]])/g;
9
+
10
+ // Gyazo URLの変種(i.gyazo.com/<hash>.<ext>、/raw付き、t.gyazo.com/teams/<team>/<hash>)
11
+ // もまとめてマッチし、hashでoEmbedを引く。FILE_URL_PATTERNと同じ末尾境界でquery/hash付きは残す
12
+ const GYAZO_URL_PATTERN =
13
+ /https?:\/\/(?:[a-z0-9-]+\.)?gyazo\.com\/(?:teams\/[^\s/[\]]+\/)?([0-9a-f]{32})(?:\/raw|\.[A-Za-z0-9]+)?(?![^\s[\]])/g;
14
+
15
+ // 本文の行を1回のスキャンで両方置換するための結合パターン
16
+ const EMBED_URL_PATTERN = new RegExp(
17
+ `${FILE_URL_PATTERN.source}|${GYAZO_URL_PATTERN.source}`,
18
+ 'g'
19
+ );
20
+
21
+ interface FileInfo {
22
+ contentType?: string;
23
+ originalname?: string;
24
+ size?: number;
25
+ text?: string;
26
+ }
27
+
28
+ interface GyazoOEmbed {
29
+ type?: string;
30
+ url?: string;
31
+ thumbnail_url?: string;
32
+ // 読めないhashでも200が返り、width/heightが空文字になる。numberであることが
33
+ // 実体の存在確認になる
34
+ width?: number | string;
35
+ height?: number | string;
36
+ title?: string;
37
+ }
38
+
39
+ // browsePage出力全体の肥大を防ぐ。全文が必要ならreadFileInfoで取れる
40
+ const DESCRIPTION_MAX_CHARS = 2000;
41
+
42
+ // 雰囲気マークアップを1行に収めて本文の行構造を保つ
43
+ const escapeAttr = (value: string): string =>
44
+ value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
45
+
46
+ const renderFileTag = (url: string, info: FileInfo): string => {
47
+ const attrs: string[] = [];
48
+ if (info.contentType) attrs.push(`type="${escapeAttr(info.contentType)}"`);
49
+ attrs.push(`url="${escapeAttr(url)}"`);
50
+ if (info.originalname) {
51
+ attrs.push(`originalname="${escapeAttr(info.originalname)}"`);
52
+ }
53
+ if (typeof info.size === 'number') attrs.push(`size="${info.size}"`);
54
+ const text = info.text?.trim();
55
+ if (text) {
56
+ const truncated =
57
+ text.length > DESCRIPTION_MAX_CHARS
58
+ ? `${text.slice(0, DESCRIPTION_MAX_CHARS)}…`
59
+ : text;
60
+ attrs.push(`description="${escapeAttr(truncated)}"`);
61
+ }
62
+ return `<cosense:file ${attrs.join(' ')}>`;
63
+ };
64
+
65
+ const renderGyazoTag = (
66
+ url: string,
67
+ hash: string,
68
+ info: GyazoOEmbed
69
+ ): string | null => {
70
+ const attrs: string[] = [`type="${escapeAttr(info.type ?? '')}"`];
71
+ attrs.push(`url="${escapeAttr(url)}"`);
72
+ if (info.type === 'photo') {
73
+ if (typeof info.width !== 'number' || !info.url) return null;
74
+ attrs.push(`thumbnail="https://gyazo.com/${hash}/thumb/1000"`);
75
+ attrs.push(`image="${escapeAttr(info.url)}"`);
76
+ } else if (info.type === 'video') {
77
+ if (!info.thumbnail_url) return null;
78
+ attrs.push(`thumbnail="${escapeAttr(info.thumbnail_url)}"`);
79
+ } else {
80
+ return null;
81
+ }
82
+ if (typeof info.width === 'number') attrs.push(`width="${info.width}"`);
83
+ if (typeof info.height === 'number') attrs.push(`height="${info.height}"`);
84
+ if (info.title) attrs.push(`title="${escapeAttr(info.title)}"`);
85
+ return `<cosense:gyazo ${attrs.join(' ')}>`;
86
+ };
87
+
88
+ const collectMatches = (
89
+ lines: string[],
90
+ pattern: RegExp
91
+ ): Map<string, string> => {
92
+ const found = new Map<string, string>();
93
+ for (const line of lines) {
94
+ for (const m of line.matchAll(pattern)) {
95
+ found.set(m[0], m[1] as string);
96
+ }
97
+ }
98
+ return found;
99
+ };
100
+
101
+ export const fetchFileMarkups = async (
102
+ lines: string[],
103
+ pageOrigin: string,
104
+ pageCredential: Credential | undefined
105
+ ): Promise<Map<string, string>> => {
106
+ const files = collectMatches(lines, FILE_URL_PATTERN);
107
+ const gyazos = collectMatches(lines, GYAZO_URL_PATTERN);
108
+ const markups = new Map<string, string>();
109
+
110
+ // ページと同じhostのアップロードファイルだけタグ展開する。cross-originのURLに
111
+ // ページのcredential(env COSENSE_PATを含む)を送るとPAT漏洩になるため、別host・
112
+ // 不正URL・取得失敗(404・権限なし・非公開Gyazo等)はいずれもURLのまま本文に残す。
113
+ // 従来の出力と同じに劣化するだけで、AIはURLからファイルの存在を認知できる
114
+ const fileTasks = [...files].map(async ([url, fileId]) => {
115
+ try {
116
+ if (new URL(url).origin !== pageOrigin) return;
117
+ const info = (await requestJson(`${pageOrigin}/api/gcs/${fileId}/info`, {
118
+ credential: pageCredential
119
+ })) as FileInfo;
120
+ markups.set(url, renderFileTag(url, info));
121
+ } catch {}
122
+ });
123
+
124
+ // 同じhashの変種URLが複数あってもoEmbedは1回だけ引く
125
+ const gyazoInfos = new Map<string, Promise<GyazoOEmbed | null>>();
126
+ for (const hash of new Set(gyazos.values())) {
127
+ gyazoInfos.set(
128
+ hash,
129
+ requestJson(
130
+ `https://api.gyazo.com/api/oembed?url=https://gyazo.com/${hash}`
131
+ ).then(
132
+ info => info as GyazoOEmbed,
133
+ () => null
134
+ )
135
+ );
136
+ }
137
+ const gyazoTasks = [...gyazos].map(async ([url, hash]) => {
138
+ const info = await gyazoInfos.get(hash);
139
+ if (!info) return;
140
+ const markup = renderGyazoTag(url, hash, info);
141
+ if (markup) markups.set(url, markup);
142
+ });
143
+
144
+ await Promise.all([...fileTasks, ...gyazoTasks]);
145
+ return markups;
146
+ };
147
+
148
+ export const applyFileMarkup = (
149
+ line: string,
150
+ markups: Map<string, string>
151
+ ): string => {
152
+ if (markups.size === 0) return line;
153
+ let out = '';
154
+ let cursor = 0;
155
+ for (const m of line.matchAll(EMBED_URL_PATTERN)) {
156
+ const markup = markups.get(m[0]);
157
+ if (!markup) continue;
158
+ let start = m.index;
159
+ let end = start + m[0].length;
160
+ // 画像記法 [url] や強調 [[url]] はbracketごとタグに置き換える
161
+ while (line[start - 1] === '[' && line[end] === ']') {
162
+ start -= 1;
163
+ end += 1;
164
+ }
165
+ out += line.slice(cursor, start) + markup;
166
+ cursor = end;
167
+ }
168
+ return out + line.slice(cursor);
169
+ };
@@ -0,0 +1,133 @@
1
+ import { type Page, toTitleLc } from './relatedPagesFormat.ts';
2
+
3
+ interface InfoboxResultEntry {
4
+ title?: string;
5
+ infobox?: Record<string, string>;
6
+ }
7
+
8
+ export interface LiterateDatabaseSourcePage extends Page {
9
+ linksLc?: string[];
10
+ created?: number;
11
+ updated?: number;
12
+ infoboxResult?: InfoboxResultEntry[];
13
+ infoboxDisableLinks?: string[];
14
+ }
15
+
16
+ interface Links1hopResponse {
17
+ links1hop?: LiterateDatabaseSourcePage[];
18
+ pagination?: { total?: number; hasNext?: boolean };
19
+ }
20
+
21
+ export interface DefinitionPage {
22
+ title?: string;
23
+ infoboxDefinition?: string[];
24
+ }
25
+
26
+ // 定義行はタブ区切りで、第1セルが列名。第2セル以降はInfoboxが値を抜き出す時の
27
+ // 指示文なので列にしない。第1セルがオプション宣言 (ExcludeTitleLine) の行も列にしない。
28
+ // 列名をtrimしないのは意図的: infoboxResultのkeyは未trimの第1セルで生成されるため、
29
+ // trimするとlookupが外れる(オプション宣言の判定だけがtrim込みで行われる)
30
+ const parseInfoboxFieldNames = (infoboxDefinition: string[]): string[] => {
31
+ const fieldNames: string[] = [];
32
+ for (const row of infoboxDefinition) {
33
+ const firstCell = row.split('\t')[0] ?? '';
34
+ if (firstCell.trim() === 'ExcludeTitleLine') continue;
35
+ fieldNames.push(firstCell);
36
+ }
37
+ return fieldNames;
38
+ };
39
+
40
+ interface LiterateDatabaseRow {
41
+ page: LiterateDatabaseSourcePage;
42
+ infobox: Record<string, string>;
43
+ }
44
+
45
+ // 行選定はWeb UIの文芸的データベースと同じ:
46
+ // 定義ページにリンクしているページのうち、無効化されていないもの。
47
+ // 並びは通常の関連ページリストとソート基準を揃えたpageRank降順(Web UIとは異なる)
48
+ const buildRows = (
49
+ definition: DefinitionPage,
50
+ links1hop: LiterateDatabaseSourcePage[]
51
+ ): LiterateDatabaseRow[] => {
52
+ const definitionTitleLc = toTitleLc(definition.title ?? '');
53
+
54
+ const rows: LiterateDatabaseRow[] = [];
55
+ for (const page of links1hop) {
56
+ const linksLc = page.linksLc ?? [];
57
+ if (!linksLc.includes(definitionTitleLc)) continue;
58
+ if (page.infoboxDisableLinks?.includes(definitionTitleLc)) continue;
59
+
60
+ const infobox =
61
+ page.infoboxResult?.find(
62
+ entry => toTitleLc(entry.title ?? '') === definitionTitleLc
63
+ )?.infobox ?? {};
64
+ rows.push({ page, infobox });
65
+ }
66
+
67
+ rows.sort((a, b) => (b.page.pageRank ?? 0) - (a.page.pageRank ?? 0));
68
+ return rows;
69
+ };
70
+
71
+ // TSVの1ページ=1行を守るため、セル内の改行・タブを置換する。
72
+ // セル値はAIが抽出した物なので、文字列以外が混ざっていても落ちないように文字列化する
73
+ const sanitizeCell = (value: unknown): string =>
74
+ String(value ?? '')
75
+ .replace(/\t/g, ' ')
76
+ .replace(/\r\n|[\r\n]/g, ' / ');
77
+
78
+ const formatDateCell = (unixSec: number | undefined): string => {
79
+ if (typeof unixSec !== 'number') return '';
80
+ const d = new Date(unixSec * 1000);
81
+ const month = String(d.getMonth() + 1).padStart(2, '0');
82
+ const day = String(d.getDate()).padStart(2, '0');
83
+ return `${d.getFullYear()}-${month}-${day}`;
84
+ };
85
+
86
+ // 対象がinfobox定義ページなら文芸的データベースのセクションを組み立てる。
87
+ // 定義ページでなければ null。表に載せたページは seen に積み、呼び出し側の
88
+ // 1 hop link 一覧に再掲されないようにする
89
+ export const renderLiterateDatabase = (
90
+ definition: DefinitionPage,
91
+ hopValue: unknown,
92
+ seen: Set<string>
93
+ ): string | null => {
94
+ const infoboxDefinition = definition.infoboxDefinition ?? [];
95
+ if (infoboxDefinition.length === 0) return null;
96
+
97
+ const { links1hop = [], pagination } = (hopValue ?? {}) as Links1hopResponse;
98
+ const fieldNames = parseInfoboxFieldNames(infoboxDefinition);
99
+ const rows = buildRows(definition, links1hop);
100
+ for (const { page } of rows) {
101
+ seen.add(page.titleLc ?? toTitleLc(page.title));
102
+ }
103
+
104
+ const header = ['Page', 'Created', 'Updated', ...fieldNames].join('\t');
105
+ const lines = rows.map(({ page, infobox }) =>
106
+ [
107
+ sanitizeCell(page.title),
108
+ formatDateCell(page.created),
109
+ formatDateCell(page.updated),
110
+ ...fieldNames.map(field => sanitizeCell(infobox[field] ?? ''))
111
+ ].join('\t')
112
+ );
113
+
114
+ // 見出しは通常ページの「1 hop link」に揃える。「文芸的データベース」を見出し単体で
115
+ // 使うと、出力だけを読むAIが同名ページを探しに行ってしまう。ただしユーザーはこの表を
116
+ // 「文芸的データベース」「Infoboxの表」「テーブル」等とも呼ぶので、指示と結びつくよう
117
+ // 別名として括弧内に残す
118
+ const parts = [
119
+ '## 1 hop link(Infoboxの文芸的データベース、TSV形式のテーブル)',
120
+ 'Page/Created/Updated以外の列は、各ページの本文からInfoboxが抜き出した値',
121
+ [header, ...lines].join('\n')
122
+ ];
123
+ if (rows.length === 0) {
124
+ // ヘッダーだけの表は出力が途切れたようにも見えるので、0件である事を明示する
125
+ parts.push('表に載るページが無いため、行は0件');
126
+ }
127
+ if (pagination?.hasNext) {
128
+ parts.push(
129
+ `注意: 関連ページが多いため、全${pagination.total}件のうち取得できた${links1hop.length}件から表を構成しています`
130
+ );
131
+ }
132
+ return parts.join('\n\n');
133
+ };
@@ -40,7 +40,7 @@ export const buildGroups = (pages: Page[]): Group[] => {
40
40
  return groups;
41
41
  };
42
42
 
43
- const toTitleLc = (title: string): string =>
43
+ export const toTitleLc = (title: string): string =>
44
44
  title.replace(/ /g, '_').toLowerCase();
45
45
 
46
46
  export const dedupAndSortByPageRank = (