@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 +13 -1
- package/package.json +3 -3
- package/src/commands/browsePage.ts +66 -12
- package/src/commands/browseRelatedPages.ts +69 -6
- package/src/lib/fileMarkup.ts +169 -0
- package/src/lib/literateDatabase.ts +133 -0
- package/src/lib/relatedPagesFormat.ts +1 -1
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
|
-
|
|
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.
|
|
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.
|
|
33
|
-
"oxlint": "1.
|
|
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 近傍ページタイトル一覧。
|
|
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 = (
|
|
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 = (
|
|
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
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|