@kokiito0926/channeling 0.0.0 → 0.0.1
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 +32 -1
- package/index.js +53 -195
- package/lib.js +138 -0
- package/package.json +46 -43
package/README.md
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
## channeling
|
|
2
2
|
|
|
3
|
-
channelingは、[2ch](https://2ch.sc/)のスレッドなどをJSON形式で取得することができるコマンドラインのツールです。
|
|
3
|
+
channelingは、[2ch](https://2ch.sc/)のスレッドなどをJSON形式で取得することができるコマンドラインのツールです。
|
|
4
|
+
JSON形式でスレッドやレスポンスを取得することができるので、他のデータ形式に変換したりすると、大規模言語モデルに与えやすくなったりします。
|
|
5
|
+
|
|
6
|
+
## インストール
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
$ npm install --save @kokiito0926/channeling
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
$ npm install --global @kokiito0926/channeling
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 使用方法
|
|
17
|
+
|
|
18
|
+
板一覧を取得することができます。
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
$ channeling board
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
板の識別子を指定すると、スレッド一覧を取得することができます。
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
$ channeling threads news4vip
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
板の識別子とスレッドの識別子を指定すると、レスポンス一覧を取得することができます。
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
$ channeling thread news4vip 1770976897
|
|
34
|
+
```
|
|
4
35
|
|
|
5
36
|
## ライセンス
|
|
6
37
|
|
package/index.js
CHANGED
|
@@ -1,195 +1,53 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// >> $ ./index.js board
|
|
4
|
-
// >> $ ./index.js threads news4vip
|
|
5
|
-
// >> $ ./index.js thread news4vip 1770976897
|
|
6
|
-
|
|
7
|
-
import { argv } from "zx";
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
const command = argv._[0];
|
|
11
|
-
if (!command) {
|
|
12
|
-
console.error(`Error: Command is required (board, threads, thread).`);
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (boardUrl.startsWith("//")) {
|
|
55
|
-
boardUrl = "http:" + boardUrl;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
currentCategory.boards.push({
|
|
59
|
-
name: boardName,
|
|
60
|
-
url: boardUrl,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return categories;
|
|
66
|
-
} catch (err) {
|
|
67
|
-
console.error(err.message);
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function getBoardUrl(targetBoard) {
|
|
73
|
-
const categories = await getScBoardList();
|
|
74
|
-
for (const cat of categories) {
|
|
75
|
-
const board = cat.boards.find((b) => {
|
|
76
|
-
const boardDir = b.url.split("/").filter(Boolean).pop();
|
|
77
|
-
return boardDir === targetBoard;
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
if (board) return board.url;
|
|
81
|
-
}
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function getScThreadList(url) {
|
|
86
|
-
const baseUrl = url.endsWith("/") ? url : url + "/";
|
|
87
|
-
const subjectUrl = `${baseUrl}subject.txt`;
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
const response = await fetch(subjectUrl);
|
|
91
|
-
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
92
|
-
|
|
93
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
94
|
-
const buffer = Buffer.from(arrayBuffer);
|
|
95
|
-
const text = iconv.decode(buffer, "CP932");
|
|
96
|
-
|
|
97
|
-
const threads = text
|
|
98
|
-
.split("\n")
|
|
99
|
-
.filter((line) => line.includes("<>"))
|
|
100
|
-
.map((line) => {
|
|
101
|
-
const [dat, titleAndCount] = line.split("<>");
|
|
102
|
-
const match = titleAndCount.match(/(.*)\s\((\d+)\)$/);
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
id: dat.replace(".dat", ""),
|
|
106
|
-
title: match ? match[1] : titleAndCount,
|
|
107
|
-
count: match ? match[2] : "0",
|
|
108
|
-
datUrl: `${baseUrl}dat/${dat}`,
|
|
109
|
-
};
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
return threads;
|
|
113
|
-
} catch (err) {
|
|
114
|
-
console.error(err.message);
|
|
115
|
-
return [];
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function getScThreadContent(boardUrl, threadId) {
|
|
120
|
-
try {
|
|
121
|
-
const parts = boardUrl.split("/").filter(Boolean);
|
|
122
|
-
const server = parts[1]; // mi.2ch.sc
|
|
123
|
-
const board = parts[parts.length - 1]; // news4vip
|
|
124
|
-
|
|
125
|
-
const datUrl = `http://${server}/${board}/dat/${threadId}.dat`;
|
|
126
|
-
|
|
127
|
-
const response = await fetch(datUrl);
|
|
128
|
-
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
|
129
|
-
|
|
130
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
131
|
-
const buffer = Buffer.from(arrayBuffer);
|
|
132
|
-
const text = iconv.decode(buffer, "CP932");
|
|
133
|
-
|
|
134
|
-
const posts = text
|
|
135
|
-
.split("\n")
|
|
136
|
-
.filter((line) => line.includes("<>"))
|
|
137
|
-
.map((line, index) => {
|
|
138
|
-
const [name, mail, dateId, body, title] = line.split("<>");
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
number: index + 1,
|
|
142
|
-
name: name.replace(/<[^>]*>?/gm, ""), // タグ除去
|
|
143
|
-
dateId,
|
|
144
|
-
body: body.replace(/ <br> /g, "\n").trim(),
|
|
145
|
-
title: title || null,
|
|
146
|
-
};
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
return posts;
|
|
150
|
-
} catch (err) {
|
|
151
|
-
console.error(err.message);
|
|
152
|
-
return [];
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (command === "board") {
|
|
157
|
-
const menu = await getScBoardList();
|
|
158
|
-
console.log(JSON.stringify(menu, null, 2));
|
|
159
|
-
process.exit(0);
|
|
160
|
-
}
|
|
161
|
-
else if (command === "threads") {
|
|
162
|
-
const boardId = argv._[1];
|
|
163
|
-
if (!boardId) {
|
|
164
|
-
console.error("Error: Board ID is required (e.g. news4vip)");
|
|
165
|
-
process.exit(1);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const boardUrl = await getBoardUrl(boardId);
|
|
169
|
-
if (!boardUrl) {
|
|
170
|
-
console.error(`Error: Board not found: ${boardId}`);
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const threads = await getScThreadList(boardUrl);
|
|
175
|
-
console.log(JSON.stringify(threads, null, 2));
|
|
176
|
-
process.exit(0);
|
|
177
|
-
}
|
|
178
|
-
else if (command === "thread") {
|
|
179
|
-
const boardId = argv._[1];
|
|
180
|
-
const threadId = argv._[2];
|
|
181
|
-
if (!boardId || !threadId) {
|
|
182
|
-
console.error("Error: Board ID and Thread ID are required (e.g. news4vip 1770975311)");
|
|
183
|
-
process.exit(1);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const boardUrl = await getBoardUrl(boardId);
|
|
187
|
-
if (!boardUrl) {
|
|
188
|
-
console.error(`Error: Board not found: ${boardId}`);
|
|
189
|
-
process.exit(1);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const posts = await getScThreadContent(boardUrl, threadId);
|
|
193
|
-
console.log(JSON.stringify(posts, null, 2));
|
|
194
|
-
process.exit(0);
|
|
195
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// >> $ node ./index.js board
|
|
4
|
+
// >> $ node ./index.js threads news4vip
|
|
5
|
+
// >> $ node ./index.js thread news4vip 1770976897
|
|
6
|
+
|
|
7
|
+
import { argv } from "zx";
|
|
8
|
+
import { getScBoardList, getBoardUrl, getScThreadList, getScThreadContent } from "./lib.js";
|
|
9
|
+
|
|
10
|
+
const command = argv._[0];
|
|
11
|
+
if (!command) {
|
|
12
|
+
console.error(`Error: Command is required (board, threads, thread).`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (command === "board") {
|
|
17
|
+
const menu = await getScBoardList();
|
|
18
|
+
console.log(JSON.stringify(menu, null, 2));
|
|
19
|
+
process.exit(0);
|
|
20
|
+
} else if (command === "threads") {
|
|
21
|
+
const boardId = argv._[1];
|
|
22
|
+
if (!boardId) {
|
|
23
|
+
console.error("Error: Board ID is required (e.g. news4vip)");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const boardUrl = await getBoardUrl(boardId);
|
|
28
|
+
if (!boardUrl) {
|
|
29
|
+
console.error(`Error: Board not found: ${boardId}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const threads = await getScThreadList(boardUrl);
|
|
34
|
+
console.log(JSON.stringify(threads, null, 2));
|
|
35
|
+
process.exit(0);
|
|
36
|
+
} else if (command === "thread") {
|
|
37
|
+
const boardId = argv._[1];
|
|
38
|
+
const threadId = argv._[2];
|
|
39
|
+
if (!boardId || !threadId) {
|
|
40
|
+
console.error("Error: Board ID and Thread ID are required (e.g. news4vip 1770975311)");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const boardUrl = await getBoardUrl(boardId);
|
|
45
|
+
if (!boardUrl) {
|
|
46
|
+
console.error(`Error: Board not found: ${boardId}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const posts = await getScThreadContent(boardUrl, threadId);
|
|
51
|
+
console.log(JSON.stringify(posts, null, 2));
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
package/lib.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import iconv from "iconv-lite";
|
|
2
|
+
|
|
3
|
+
export const SC_MENU_URL = "http://menu.2ch.sc/bbsmenu.html";
|
|
4
|
+
|
|
5
|
+
export async function getScBoardList() {
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetch(SC_MENU_URL);
|
|
8
|
+
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
|
9
|
+
|
|
10
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
11
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
12
|
+
|
|
13
|
+
// CP932でデコード
|
|
14
|
+
const html = iconv.decode(buffer, "CP932");
|
|
15
|
+
|
|
16
|
+
const categories = [];
|
|
17
|
+
let currentCategory = null;
|
|
18
|
+
|
|
19
|
+
// 行ごとに分割してパース
|
|
20
|
+
const lines = html.split("\n");
|
|
21
|
+
|
|
22
|
+
for (let line of lines) {
|
|
23
|
+
// カテゴリ名 (例: <B>ニュース</B>)
|
|
24
|
+
const catMatch = line.match(/<B>(.*?)<\/B>/);
|
|
25
|
+
if (catMatch) {
|
|
26
|
+
currentCategory = { category: catMatch[1], boards: [] };
|
|
27
|
+
categories.push(currentCategory);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 修正後の正規表現
|
|
32
|
+
// 1. HREF= の直後にある // から始まる文字列をキャプチャ
|
|
33
|
+
// 2. > までの間の文字を板名としてキャプチャ
|
|
34
|
+
const boardMatch = line.match(/<A HREF=(\/\/.*?\/)>(.*?)<\/A>/);
|
|
35
|
+
|
|
36
|
+
if (boardMatch && currentCategory) {
|
|
37
|
+
let boardUrl = boardMatch[1];
|
|
38
|
+
const boardName = boardMatch[2];
|
|
39
|
+
|
|
40
|
+
// //ai.2ch.sc/kokusai/ のような形式を http: に補完
|
|
41
|
+
if (boardUrl.startsWith("//")) {
|
|
42
|
+
boardUrl = "http:" + boardUrl;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
currentCategory.boards.push({
|
|
46
|
+
name: boardName,
|
|
47
|
+
url: boardUrl,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return categories;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getBoardUrl(targetBoard) {
|
|
59
|
+
const categories = await getScBoardList();
|
|
60
|
+
for (const cat of categories) {
|
|
61
|
+
const board = cat.boards.find((b) => {
|
|
62
|
+
const boardDir = b.url.split("/").filter(Boolean).pop();
|
|
63
|
+
return boardDir === targetBoard;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (board) return board.url;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function getScThreadList(url) {
|
|
72
|
+
const baseUrl = url.endsWith("/") ? url : url + "/";
|
|
73
|
+
const subjectUrl = `${baseUrl}subject.txt`;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(subjectUrl);
|
|
77
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
78
|
+
|
|
79
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
80
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
81
|
+
const text = iconv.decode(buffer, "CP932");
|
|
82
|
+
|
|
83
|
+
const threads = text
|
|
84
|
+
.split("\n")
|
|
85
|
+
.filter((line) => line.includes("<>"))
|
|
86
|
+
.map((line) => {
|
|
87
|
+
const [dat, titleAndCount] = line.split("<>");
|
|
88
|
+
const match = titleAndCount.match(/(.*)\s\((\d+)\)$/);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
id: dat.replace(".dat", ""),
|
|
92
|
+
title: match ? match[1] : titleAndCount,
|
|
93
|
+
count: match ? match[2] : "0",
|
|
94
|
+
datUrl: `${baseUrl}dat/${dat}`,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return threads;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function getScThreadContent(boardUrl, threadId) {
|
|
105
|
+
try {
|
|
106
|
+
const parts = boardUrl.split("/").filter(Boolean);
|
|
107
|
+
const server = parts[1]; // mi.2ch.sc
|
|
108
|
+
const board = parts[parts.length - 1]; // news4vip
|
|
109
|
+
|
|
110
|
+
const datUrl = `http://${server}/${board}/dat/${threadId}.dat`;
|
|
111
|
+
|
|
112
|
+
const response = await fetch(datUrl);
|
|
113
|
+
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
|
114
|
+
|
|
115
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
116
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
117
|
+
const text = iconv.decode(buffer, "CP932");
|
|
118
|
+
|
|
119
|
+
const posts = text
|
|
120
|
+
.split("\n")
|
|
121
|
+
.filter((line) => line.includes("<>"))
|
|
122
|
+
.map((line, index) => {
|
|
123
|
+
const [name, mail, dateId, body, title] = line.split("<>");
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
number: index + 1,
|
|
127
|
+
name: name.replace(/<[^>]*>?/gm, ""), // タグ除去
|
|
128
|
+
dateId,
|
|
129
|
+
body: body.replace(/ <br> /g, "\n").trim(),
|
|
130
|
+
title: title || null,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return posts;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,43 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@kokiito0926/channeling",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"private": false,
|
|
5
|
-
"description": "2chのスレッドなどをJSON形式で取得することができるコマンドラインのツールです。",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"cli",
|
|
8
|
-
"bash",
|
|
9
|
-
"shell",
|
|
10
|
-
"script",
|
|
11
|
-
"nodejs",
|
|
12
|
-
"2ch",
|
|
13
|
-
"scraping",
|
|
14
|
-
"channeling"
|
|
15
|
-
],
|
|
16
|
-
"homepage": "https://github.com/kokiito0926/channeling#readme",
|
|
17
|
-
"publishConfig": {
|
|
18
|
-
"access": "public"
|
|
19
|
-
},
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
},
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
}
|
|
43
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@kokiito0926/channeling",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "2chのスレッドなどをJSON形式で取得することができるコマンドラインのツールです。",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"cli",
|
|
8
|
+
"bash",
|
|
9
|
+
"shell",
|
|
10
|
+
"script",
|
|
11
|
+
"nodejs",
|
|
12
|
+
"2ch",
|
|
13
|
+
"scraping",
|
|
14
|
+
"channeling"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/kokiito0926/channeling#readme",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./lib.js"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"channeling": "index.js"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/kokiito0926/channeling/issues"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/kokiito0926/channeling.git"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"author": "Koki Ito <kokiito0926@gmail.com>",
|
|
35
|
+
"type": "module",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"iconv-lite": "^0.7.2",
|
|
41
|
+
"zx": "^8.8.4"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=24.0.0"
|
|
45
|
+
}
|
|
46
|
+
}
|