@helpfeel/cosense-cli 1.6.0 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.6.0",
3
+ "version": "1.9.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": {
package/src/cli.ts CHANGED
@@ -17,6 +17,11 @@ import {
17
17
  browseRelatedPagesHelp,
18
18
  browseRelatedPagesSummary
19
19
  } from './commands/browseRelatedPages.ts';
20
+ import {
21
+ downloadFile,
22
+ downloadFileHelp,
23
+ downloadFileSummary
24
+ } from './commands/downloadFile.ts';
20
25
  import {
21
26
  list1hopLinks,
22
27
  list1hopLinksHelp,
@@ -43,6 +48,11 @@ import {
43
48
  previewEditHelp,
44
49
  previewEditSummary
45
50
  } from './commands/previewEdit.ts';
51
+ import {
52
+ readFileInfo,
53
+ readFileInfoHelp,
54
+ readFileInfoSummary
55
+ } from './commands/readFileInfo.ts';
46
56
  import {
47
57
  readPage,
48
58
  readPageHelp,
@@ -119,6 +129,16 @@ const commands: Record<string, CommandSpec> = {
119
129
  help: browseRelatedPagesHelp
120
130
  },
121
131
  readPage: { handler: readPage, summary: readPageSummary, help: readPageHelp },
132
+ readFileInfo: {
133
+ handler: readFileInfo,
134
+ summary: readFileInfoSummary,
135
+ help: readFileInfoHelp
136
+ },
137
+ downloadFile: {
138
+ handler: downloadFile,
139
+ summary: downloadFileSummary,
140
+ help: downloadFileHelp
141
+ },
122
142
  readProjectMembers: {
123
143
  handler: readProjectMembers,
124
144
  summary: readProjectMembersSummary,
@@ -1,4 +1,5 @@
1
1
  import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { renderLiterateDatabase } from '../lib/literateDatabase.ts';
2
3
  import { parsePageUrl } from '../lib/parseUrl.ts';
3
4
  import { fetchRelatedPages } from '../lib/relatedPages.ts';
4
5
  import {
@@ -60,7 +61,11 @@ Usage:
60
61
  本文と関連ページ一覧の境界を示す非Markdown区切り線。Cosenseの#hashtag記法と
61
62
  衝突しないようにMarkdown見出しを避ける。
62
63
  ## 1 hop link
63
- このページの 1-hop 近傍ページタイトル一覧。 1-hop が 0 件なら区切り線ごと省略
64
+ このページの 1-hop 近傍ページタイトル一覧。 0 件なら区切り線ごと省略。
65
+ このページがinfobox定義ページ(本文に table:infobox または table:cosense を宣言している)の
66
+ 時は ## 1 hop link(Infoboxの文芸的データベース、TSV形式のテーブル) に変わる。表の構造は
67
+ cosense browseRelatedPages --help を参照。表に載らなかったページは
68
+ ## 1 hop link(表に載っていないページ) に出力する
64
69
 
65
70
  persistent: false の時:
66
71
  メタデータ・アイコン・テロメア・Infoboxは省略。 (このページはまだ作成されていません) と
@@ -119,6 +124,7 @@ interface PageData {
119
124
  lines?: PageLine[];
120
125
  icons?: string[];
121
126
  infoboxResult?: InfoboxResult[];
127
+ infoboxDefinition?: string[];
122
128
  }
123
129
 
124
130
  const LINE_ID_PATTERN = /^[0-9a-f]{24}$/;
@@ -283,11 +289,26 @@ const renderBody = (lines: PageLine[], fragment: string | null): BodyRender => {
283
289
  return { body: out.join('\n'), matchedFragment };
284
290
  };
285
291
 
286
- const renderRelatedPages = (hopValue: unknown): string | null => {
292
+ const renderRelatedPages = (
293
+ hopValue: unknown,
294
+ page: PageData
295
+ ): string | null => {
296
+ const sections: string[] = [];
297
+ // seen は文芸的データベースと 1 hop link で共有して、表に載ったページを再掲しないようにする
298
+ const seen = new Set<string>();
299
+ const literateDatabase = renderLiterateDatabase(page, hopValue, seen);
300
+ if (literateDatabase) sections.push(literateDatabase);
301
+
287
302
  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))}`;
303
+ const pages = dedupAndSortByPageRank(links1hop, seen);
304
+ if (pages.length > 0) {
305
+ const heading = literateDatabase
306
+ ? '## 1 hop link(表に載っていないページ)'
307
+ : '## 1 hop link';
308
+ sections.push(`${heading}\n\n${renderGroups(buildGroups(pages))}`);
309
+ }
310
+ if (sections.length === 0) return null;
311
+ return `-------------------- Related Pages --------------------\n\n${sections.join('\n\n')}`;
291
312
  };
292
313
 
293
314
  export const browsePage = async (args: string[]): Promise<void> => {
@@ -317,7 +338,7 @@ export const browsePage = async (args: string[]): Promise<void> => {
317
338
  sections.push('(このページはまだ作成されていません)');
318
339
  const { body } = renderBody(page.lines ?? [], null);
319
340
  sections.push(`## 本文(テンプレート)\n\n${body}`);
320
- const related = renderRelatedPages(hopValue);
341
+ const related = renderRelatedPages(hopValue, page);
321
342
  if (related) sections.push(related);
322
343
  process.stdout.write(`${sections.join('\n\n')}\n`);
323
344
  return;
@@ -368,7 +389,7 @@ export const browsePage = async (args: string[]): Promise<void> => {
368
389
 
369
390
  sections.push(`## 本文\n\n${body}`);
370
391
 
371
- const related = renderRelatedPages(hopValue);
392
+ const related = renderRelatedPages(hopValue, page);
372
393
  if (related) sections.push(related);
373
394
 
374
395
  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,90 @@
1
+ import { parseFileUrl } from '../lib/parseUrl.ts';
2
+ import { downloadToFile } from '../lib/request.ts';
3
+ import { resolveFileCredential } from '../lib/resolveFileCredential.ts';
4
+
5
+ export const downloadFileSummary =
6
+ 'ファイル本体をダウンロードしてローカルに保存する';
7
+
8
+ export const downloadFileHelp = `downloadFile - ファイル本体をダウンロードしてローカルに保存する
9
+
10
+ Usage:
11
+ cosense downloadFile <fileUrl> <outputPath> [--thumbnail] [--project <projectUrl>]
12
+
13
+ 引数:
14
+ <fileUrl> ファイルのURL(例: https://scrapbox.io/files/5f151efbacbb17001a58f120.png)。query/hashは付けない
15
+ <outputPath> 保存先ファイルパス。既存ファイルは上書きする。親ディレクトリは自動作成しない
16
+
17
+ オプション:
18
+ --thumbnail 縮小版(thumbnail)を取得する。thumbnailが存在しないファイル(jpeg/png以外)は原本が返る
19
+ --project <projectUrl> Service Account認証で取得する時に、ファイルが属するprojectのURLを指定する(例: https://scrapbox.io/example)。省略時はPersonal Access Tokenを使う
20
+
21
+ 出力(JSON):
22
+ path string 保存したファイルの絶対パス
23
+ contentType string | null 取得したファイルのContent-Type
24
+ size number 保存したファイルのbyte数
25
+
26
+ 例:
27
+ cosense downloadFile 'https://scrapbox.io/files/5f151efbacbb17001a58f120.png' ./image.png
28
+ cosense downloadFile 'https://scrapbox.io/files/5f151efbacbb17001a58f120.png' /tmp/thumb.png --thumbnail
29
+
30
+ HTTPエラー:
31
+ 401/403: 認証・権限が無い。private projectのファイルはproject member権限が必要
32
+ 404: fileIdに対応するファイルが存在しない
33
+
34
+ 注記:
35
+ 動画ファイルはサーバー側の制限により取得できない場合がある
36
+ `;
37
+
38
+ interface ParsedArgs {
39
+ fileUrl: string;
40
+ outputPath: string;
41
+ thumbnail: boolean;
42
+ project?: string;
43
+ }
44
+
45
+ const parseArgs = (args: string[]): ParsedArgs => {
46
+ const usage =
47
+ 'Usage: cosense downloadFile <fileUrl> <outputPath> [--thumbnail] [--project <projectUrl>]';
48
+ let thumbnail = false;
49
+ let project: string | undefined;
50
+ const positional: string[] = [];
51
+ for (let i = 0; i < args.length; i += 1) {
52
+ const arg = args[i] as string;
53
+ if (arg === '--thumbnail') {
54
+ thumbnail = true;
55
+ } else if (arg === '--project') {
56
+ if (project !== undefined) {
57
+ throw new Error(`--project specified multiple times\n${usage}`);
58
+ }
59
+ const value = args[i + 1];
60
+ if (value === undefined || value.startsWith('--')) {
61
+ throw new Error(`--project requires a value\n${usage}`);
62
+ }
63
+ project = value;
64
+ i += 1;
65
+ } else if (arg.startsWith('--')) {
66
+ throw new Error(`Unknown option: ${arg}\n${usage}`);
67
+ } else {
68
+ positional.push(arg);
69
+ }
70
+ }
71
+ if (positional.length !== 2) {
72
+ throw new Error(usage);
73
+ }
74
+ return {
75
+ fileUrl: positional[0] as string,
76
+ outputPath: positional[1] as string,
77
+ thumbnail,
78
+ project
79
+ };
80
+ };
81
+
82
+ export const downloadFile = async (args: string[]): Promise<void> => {
83
+ const { fileUrl, outputPath, thumbnail, project } = parseArgs(args);
84
+ const { origin, fileId } = parseFileUrl(fileUrl);
85
+ const credential = resolveFileCredential(origin, project);
86
+ let requestUrl = `${origin}/files/${fileId}`;
87
+ if (thumbnail) requestUrl += '?type=thumbnail';
88
+ const result = await downloadToFile(requestUrl, outputPath, { credential });
89
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
90
+ };
@@ -0,0 +1,81 @@
1
+ import { parseFileUrl } from '../lib/parseUrl.ts';
2
+ import { requestJson } from '../lib/request.ts';
3
+ import { resolveFileCredential } from '../lib/resolveFileCredential.ts';
4
+
5
+ export const readFileInfoSummary =
6
+ 'ファイルのメタデータと抽出済みテキストを取得する';
7
+
8
+ export const readFileInfoHelp = `readFileInfo - ファイルのメタデータと抽出済みテキストを取得する
9
+
10
+ Usage:
11
+ cosense readFileInfo <fileUrl> [--project <projectUrl>]
12
+
13
+ 引数:
14
+ <fileUrl> ファイルのURL(例: https://scrapbox.io/files/5f151efbacbb17001a58f120.pdf)。query/hashは付けない
15
+
16
+ オプション:
17
+ --project <projectUrl> Service Account認証で取得する時に、ファイルが属するprojectのURLを指定する(例: https://scrapbox.io/example)。省略時はPersonal Access Tokenを使う
18
+
19
+ 戻り値(top-levelの主なkey):
20
+ id string ファイルID
21
+ projectName string ファイルが属するproject名
22
+ text string? ファイルから抽出されたテキスト(画像のOCR、PDFの本文等)。先頭10000文字まで
23
+ originalname string? アップロード時のファイル名
24
+ contentType string? ファイルのContent-Type
25
+ size number? ファイルのbyte数
26
+
27
+ 例:
28
+ cosense readFileInfo 'https://scrapbox.io/files/5f151efbacbb17001a58f120.pdf'
29
+
30
+ 絞り込み例(jqで欲しい部分だけ抜き出す):
31
+ 抽出済みテキストだけ:
32
+ cosense readFileInfo <fileUrl> | jq -r '.text'
33
+
34
+ HTTPエラー:
35
+ 401/403: 認証・権限が無い。private projectのファイルはproject member権限が必要
36
+ 404: fileIdに対応するファイルが存在しない
37
+ `;
38
+
39
+ interface ParsedArgs {
40
+ fileUrl: string;
41
+ project?: string;
42
+ }
43
+
44
+ const parseArgs = (args: string[]): ParsedArgs => {
45
+ const usage =
46
+ 'Usage: cosense readFileInfo <fileUrl> [--project <projectUrl>]';
47
+ let project: string | undefined;
48
+ const positional: string[] = [];
49
+ for (let i = 0; i < args.length; i += 1) {
50
+ const arg = args[i] as string;
51
+ if (arg === '--project') {
52
+ if (project !== undefined) {
53
+ throw new Error(`--project specified multiple times\n${usage}`);
54
+ }
55
+ const value = args[i + 1];
56
+ if (value === undefined || value.startsWith('--')) {
57
+ throw new Error(`--project requires a value\n${usage}`);
58
+ }
59
+ project = value;
60
+ i += 1;
61
+ } else if (arg.startsWith('--')) {
62
+ throw new Error(`Unknown option: ${arg}\n${usage}`);
63
+ } else {
64
+ positional.push(arg);
65
+ }
66
+ }
67
+ if (positional.length !== 1) {
68
+ throw new Error(usage);
69
+ }
70
+ return { fileUrl: positional[0] as string, project };
71
+ };
72
+
73
+ export const readFileInfo = async (args: string[]): Promise<void> => {
74
+ const { fileUrl, project } = parseArgs(args);
75
+ const { origin, fileId } = parseFileUrl(fileUrl);
76
+ const credential = resolveFileCredential(origin, project);
77
+ const data = await requestJson(`${origin}/api/gcs/${fileId}/info`, {
78
+ credential
79
+ });
80
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
81
+ };
@@ -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
+ };
@@ -45,6 +45,30 @@ export const parseProjectUrlStrict = (input: string): ProjectUrl => {
45
45
  return { origin: u.origin, projectName: m[1] as string };
46
46
  };
47
47
 
48
+ export interface FileUrl {
49
+ origin: string;
50
+ fileId: string;
51
+ }
52
+
53
+ export const parseFileUrl = (input: string): FileUrl => {
54
+ const u = new URL(input);
55
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
56
+ throw new Error(`File URL must use http: or https: scheme: ${input}`);
57
+ }
58
+ if (u.search || u.hash) {
59
+ throw new Error(
60
+ `File URL must not have query/hash (use --thumbnail to fetch thumbnail), got: ${input}`
61
+ );
62
+ }
63
+ const m = u.pathname.match(/^\/files\/([0-9a-f]{24})(?:\..*)?$/);
64
+ if (!m) {
65
+ throw new Error(
66
+ `File URL must be https://<host>/files/<fileId>[.<ext>], got: ${input}`
67
+ );
68
+ }
69
+ return { origin: u.origin, fileId: m[1] as string };
70
+ };
71
+
48
72
  export const parsePageUrl = (input: string): PageUrl => {
49
73
  const u = new URL(input);
50
74
  const m = u.pathname.match(/^\/([^/]+)\/(.+?)\/?$/);
@@ -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 = (
@@ -1,3 +1,10 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { rename, rm, stat } from 'node:fs/promises';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import type { Credential } from './settings.ts';
7
+
1
8
  export class HttpError extends Error {
2
9
  readonly status: number;
3
10
  readonly statusText: string;
@@ -31,20 +38,16 @@ export class HttpError extends Error {
31
38
  }
32
39
  }
33
40
 
34
- import type { Credential } from './settings.ts';
35
-
36
41
  interface RequestOptions {
37
42
  credential?: Credential;
38
43
  method?: 'GET' | 'POST';
39
44
  body?: unknown;
40
45
  }
41
46
 
42
- export const requestJson = async (
43
- url: string,
44
- options?: RequestOptions
45
- ): Promise<unknown> => {
47
+ const buildCredentialHeaders = (
48
+ credential?: Credential
49
+ ): Record<string, string> => {
46
50
  const headers: Record<string, string> = {};
47
- const credential = options?.credential;
48
51
  if (credential) {
49
52
  if (credential.type === 'serviceAccount') {
50
53
  headers['x-service-account-access-key'] = credential.value;
@@ -52,6 +55,14 @@ export const requestJson = async (
52
55
  headers['x-personal-access-token'] = credential.value;
53
56
  }
54
57
  }
58
+ return headers;
59
+ };
60
+
61
+ export const requestJson = async (
62
+ url: string,
63
+ options?: RequestOptions
64
+ ): Promise<unknown> => {
65
+ const headers = buildCredentialHeaders(options?.credential);
55
66
  const method = options?.method ?? 'GET';
56
67
  const init: RequestInit = { method, headers };
57
68
  if (options?.body !== undefined) {
@@ -70,3 +81,82 @@ export const requestJson = async (
70
81
  }
71
82
  return res.json();
72
83
  };
84
+
85
+ export interface DownloadResult {
86
+ path: string;
87
+ contentType: string | null;
88
+ size: number;
89
+ }
90
+
91
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
92
+
93
+ export const downloadToFile = async (
94
+ url: string,
95
+ outputPath: string,
96
+ options?: { credential?: Credential }
97
+ ): Promise<DownloadResult> => {
98
+ const absPath = resolve(outputPath);
99
+ const targetStat = await stat(absPath).catch(() => null);
100
+ if (targetStat?.isDirectory()) {
101
+ throw new Error(`<outputPath> is a directory: ${outputPath}`);
102
+ }
103
+ const parentDir = dirname(absPath);
104
+ const parentStat = await stat(parentDir).catch(() => null);
105
+ if (!parentStat?.isDirectory()) {
106
+ throw new Error(`Parent directory does not exist: ${parentDir}`);
107
+ }
108
+
109
+ const headers = buildCredentialHeaders(options?.credential);
110
+ let res = await fetch(url, { headers, redirect: 'manual' });
111
+ if (REDIRECT_STATUSES.has(res.status)) {
112
+ const location = res.headers.get('location');
113
+ await res.body?.cancel();
114
+ if (!location) {
115
+ throw new Error(`HTTP ${res.status} without Location header: ${url}`);
116
+ }
117
+ const redirectUrl = new URL(location, url);
118
+ // credential headerは別originのredirect先に転送しない
119
+ res = await fetch(redirectUrl);
120
+ if (!res.ok) {
121
+ await res.body?.cancel();
122
+ throw new Error(
123
+ `HTTP ${res.status} ${res.statusText} from ${redirectUrl.origin}${redirectUrl.pathname}`
124
+ );
125
+ }
126
+ } else if (!res.ok) {
127
+ const body = await res.text().catch(() => '');
128
+ throw new HttpError({
129
+ status: res.status,
130
+ statusText: res.statusText,
131
+ url,
132
+ body
133
+ });
134
+ }
135
+ if (!res.body) {
136
+ throw new Error(`Empty response body: ${url}`);
137
+ }
138
+
139
+ const tmpPath = join(
140
+ parentDir,
141
+ `.${basename(absPath)}.${process.pid}.${Math.random().toString(36).slice(2)}.part`
142
+ );
143
+ try {
144
+ await pipeline(
145
+ Readable.fromWeb(res.body),
146
+ createWriteStream(tmpPath, { flags: 'wx' })
147
+ );
148
+ const { size } = await stat(tmpPath);
149
+ await rename(tmpPath, absPath);
150
+ return {
151
+ path: absPath,
152
+ contentType: res.headers.get('content-type'),
153
+ size
154
+ };
155
+ } catch (err) {
156
+ await rm(tmpPath, { force: true }).catch(() => {});
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ throw new Error(
159
+ `Failed to write ${absPath}: ${message.replaceAll(tmpPath, absPath)}`
160
+ );
161
+ }
162
+ };
@@ -0,0 +1,30 @@
1
+ import { parseProjectUrlStrict } from './parseUrl.ts';
2
+ import {
3
+ resolveCredential,
4
+ resolveUserCredential,
5
+ type Credential
6
+ } from './settings.ts';
7
+
8
+ // fileUrlにはproject名が含まれないため、Service Account(project単位)を
9
+ // 使う時だけ--projectでprojectUrlを受け取って解決する
10
+ export const resolveFileCredential = (
11
+ fileOrigin: string,
12
+ projectUrl: string | undefined
13
+ ): Credential | undefined => {
14
+ if (projectUrl === undefined) {
15
+ return resolveUserCredential(fileOrigin);
16
+ }
17
+ const { origin, projectName } = parseProjectUrlStrict(projectUrl);
18
+ if (origin !== fileOrigin) {
19
+ throw new Error(
20
+ `--project origin mismatch: ${origin} (--project) vs ${fileOrigin} (file URL)`
21
+ );
22
+ }
23
+ const credential = resolveCredential(origin, projectName);
24
+ if (!credential) {
25
+ throw new Error(
26
+ `No credential found for --project ${projectUrl}. Run \`cosense login ${projectUrl}\` to authenticate.`
27
+ );
28
+ }
29
+ return credential;
30
+ };