ones-fetch 1.0.0 → 1.2.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 +12 -1
- package/bin/install.mjs +33 -2
- package/package.json +9 -3
- package/public/icon.ico +0 -0
- package/public/index.html +84 -21
- package/public/launcher.vbs +4 -23
- package/src/server.mjs +33 -0
- package/.claude/settings.local.json +0 -19
- package/public/icon.png +0 -0
package/README.md
CHANGED
|
@@ -10,11 +10,22 @@
|
|
|
10
10
|
npx ones-fetch
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
或者使用 npm 安装:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g ones-fetch
|
|
17
|
+
```
|
|
18
|
+
|
|
13
19
|
这会自动:
|
|
14
|
-
- 安装依赖(约 30MB
|
|
20
|
+
- 安装依赖(约 30MB,仅首次需要)
|
|
15
21
|
- 在桌面创建快捷方式
|
|
16
22
|
- 完成后双击桌面图标即可使用
|
|
17
23
|
|
|
24
|
+
**注意**:
|
|
25
|
+
- 依赖只需安装一次,后续启动无需重新安装
|
|
26
|
+
- 服务器会在浏览器关闭 2 分钟后自动退出
|
|
27
|
+
- 解析任务时会显示实时进度(1/7)
|
|
28
|
+
|
|
18
29
|
---
|
|
19
30
|
|
|
20
31
|
## 环境要求
|
package/bin/install.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const projectRoot = join(__dirname, '..');
|
|
|
14
14
|
async function createWindowsShortcut() {
|
|
15
15
|
const desktop = join(homedir(), 'Desktop');
|
|
16
16
|
const shortcutPath = join(desktop, 'ONES 采集工具.lnk');
|
|
17
|
-
const iconPath = join(projectRoot, 'public', 'icon.
|
|
17
|
+
const iconPath = join(projectRoot, 'public', 'icon.ico');
|
|
18
18
|
const vbsLauncher = join(projectRoot, 'public', 'launcher.vbs');
|
|
19
19
|
|
|
20
20
|
// 创建 PowerShell 脚本来生成快捷方式
|
|
@@ -24,7 +24,7 @@ $Shortcut = $WshShell.CreateShortcut("${shortcutPath.replace(/\\/g, '\\\\')}")
|
|
|
24
24
|
$Shortcut.TargetPath = "wscript.exe"
|
|
25
25
|
$Shortcut.Arguments = '"${vbsLauncher.replace(/\\/g, '\\\\')}"'
|
|
26
26
|
$Shortcut.WorkingDirectory = "${projectRoot.replace(/\\/g, '\\\\')}"
|
|
27
|
-
$Shortcut.IconLocation = "${iconPath.replace(/\\/g, '\\\\')}"
|
|
27
|
+
$Shortcut.IconLocation = "${iconPath.replace(/\\/g, '\\\\')},0"
|
|
28
28
|
$Shortcut.Description = "ONES 任务采集工具"
|
|
29
29
|
$Shortcut.Save()
|
|
30
30
|
`;
|
|
@@ -79,6 +79,37 @@ Categories=Utility;
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
async function main() {
|
|
82
|
+
// 检测是否是通过 npx 或 postinstall 运行
|
|
83
|
+
const isPostInstall = process.env.npm_lifecycle_event === 'postinstall';
|
|
84
|
+
|
|
85
|
+
if (isPostInstall) {
|
|
86
|
+
// postinstall 时只创建快捷方式,不安装依赖
|
|
87
|
+
console.log('ONES Fetch 安装后配置\n');
|
|
88
|
+
console.log('正在创建桌面快捷方式...');
|
|
89
|
+
try {
|
|
90
|
+
const os = platform();
|
|
91
|
+
if (os === 'win32') {
|
|
92
|
+
await createWindowsShortcut();
|
|
93
|
+
} else if (os === 'darwin') {
|
|
94
|
+
await createMacShortcut();
|
|
95
|
+
} else {
|
|
96
|
+
await createLinuxShortcut();
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error('✗ 快捷方式创建失败:', err.message);
|
|
100
|
+
// 不退出,允许安装继续
|
|
101
|
+
}
|
|
102
|
+
console.log('\n✓ 配置完成!');
|
|
103
|
+
console.log('\n使用方法:');
|
|
104
|
+
console.log(' 1. 双击桌面上的 "ONES 采集工具" 图标');
|
|
105
|
+
console.log(' 2. 浏览器会自动打开工具页面');
|
|
106
|
+
console.log('\n或者在命令行运行:');
|
|
107
|
+
console.log(` cd ${projectRoot}`);
|
|
108
|
+
console.log(' npm start');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// npx 运行时的完整安装流程
|
|
82
113
|
console.log('ONES Fetch 安装程序\n');
|
|
83
114
|
|
|
84
115
|
// 安装依赖
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ones-fetch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ONES Fetch — Web app for recursive ONES subtask crawling",
|
|
6
6
|
"bin": {
|
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node src/server.mjs",
|
|
11
|
-
"dev": "node --watch src/server.mjs"
|
|
11
|
+
"dev": "node --watch src/server.mjs",
|
|
12
|
+
"postinstall": "node bin/install.mjs"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
15
|
"playwright-core": "^1.58.2"
|
|
15
16
|
},
|
|
16
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ones",
|
|
19
|
+
"task",
|
|
20
|
+
"crawler",
|
|
21
|
+
"project-management"
|
|
22
|
+
],
|
|
17
23
|
"author": "",
|
|
18
24
|
"license": "MIT"
|
|
19
25
|
}
|
package/public/icon.ico
ADDED
|
Binary file
|
package/public/index.html
CHANGED
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
|
50
50
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
51
51
|
.copied { background: #10b981 !important; }
|
|
52
|
+
.progress-bar { width: 100%; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; margin-top: 8px; }
|
|
53
|
+
.progress-fill { height: 100%; background: linear-gradient(90deg, #6366f1, #8b5cf6); transition: width 0.3s ease; border-radius: 3px; }
|
|
54
|
+
.progress-text { font-size: 0.8125rem; color: #6b7280; margin-top: 4px; }
|
|
52
55
|
</style>
|
|
53
56
|
</head>
|
|
54
57
|
<body>
|
|
@@ -65,6 +68,12 @@
|
|
|
65
68
|
<span class="status" id="status"></span>
|
|
66
69
|
</div>
|
|
67
70
|
<div class="hint" id="authHint"></div>
|
|
71
|
+
<div id="progressContainer" style="display:none">
|
|
72
|
+
<div class="progress-bar">
|
|
73
|
+
<div class="progress-fill" id="progressFill" style="width:0%"></div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="progress-text" id="progressText"></div>
|
|
76
|
+
</div>
|
|
68
77
|
<div class="preview-chips" id="chips"></div>
|
|
69
78
|
</div>
|
|
70
79
|
|
|
@@ -99,6 +108,23 @@ let allTasks = [];
|
|
|
99
108
|
let collapsed = new Set();
|
|
100
109
|
let authPollingTimer = null;
|
|
101
110
|
let authContext = { baseUrl: '', teamId: '' };
|
|
111
|
+
let heartbeatTimer = null;
|
|
112
|
+
|
|
113
|
+
// Start heartbeat to keep server alive
|
|
114
|
+
function startHeartbeat() {
|
|
115
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
116
|
+
heartbeatTimer = setInterval(() => {
|
|
117
|
+
fetch('/api/heartbeat').catch(() => {});
|
|
118
|
+
}, 60000); // Every 60 seconds
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Stop heartbeat when page unloads
|
|
122
|
+
window.addEventListener('beforeunload', () => {
|
|
123
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Start heartbeat on page load
|
|
127
|
+
startHeartbeat();
|
|
102
128
|
|
|
103
129
|
function extractContext(text) {
|
|
104
130
|
const match = text.match(/(https?:\/\/[^/\s]+)\/project\/#\/team\/([^/\s]+)\/task\/([A-Za-z0-9]{10,})/);
|
|
@@ -157,6 +183,22 @@ function renderChips(taskIds) {
|
|
|
157
183
|
el.innerHTML = taskIds.map(id => `<span class="chip">${id}</span>`).join('');
|
|
158
184
|
}
|
|
159
185
|
|
|
186
|
+
function updateProgress(current, total, message) {
|
|
187
|
+
const container = document.getElementById('progressContainer');
|
|
188
|
+
const fill = document.getElementById('progressFill');
|
|
189
|
+
const text = document.getElementById('progressText');
|
|
190
|
+
|
|
191
|
+
if (total === 0) {
|
|
192
|
+
container.style.display = 'none';
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
container.style.display = 'block';
|
|
197
|
+
const percent = Math.round((current / total) * 100);
|
|
198
|
+
fill.style.width = percent + '%';
|
|
199
|
+
text.textContent = `${message} (${current}/${total})`;
|
|
200
|
+
}
|
|
201
|
+
|
|
160
202
|
async function parse() {
|
|
161
203
|
const text = document.getElementById('input').value.trim();
|
|
162
204
|
if (!text) { setStatus('请先粘贴文本', true); return; }
|
|
@@ -168,40 +210,60 @@ async function parse() {
|
|
|
168
210
|
setStatus(`已提取 ${taskIds.length} 个任务 ID,正在抓取...`);
|
|
169
211
|
setAuthHint(context.baseUrl ? `已识别站点:${context.baseUrl}${context.teamId ? `,团队:${context.teamId}` : ''}` : '');
|
|
170
212
|
document.getElementById('parseBtn').disabled = true;
|
|
213
|
+
updateProgress(0, taskIds.length, '准备开始');
|
|
171
214
|
document.getElementById('tbody').innerHTML = '<tr><td colspan="5" class="loading"><span class="spinner"></span>加载中...</td></tr>';
|
|
172
215
|
document.getElementById('resultsSection').style.display = '';
|
|
173
216
|
|
|
174
217
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
218
|
+
// 逐个解析任务以显示进度
|
|
219
|
+
const allTasks = [];
|
|
220
|
+
const roots = [];
|
|
221
|
+
|
|
222
|
+
for (let i = 0; i < taskIds.length; i++) {
|
|
223
|
+
updateProgress(i + 1, taskIds.length, `正在解析任务 ${taskIds[i]}`);
|
|
224
|
+
|
|
225
|
+
const resp = await fetch('/api/crawl', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
taskIds: [taskIds[i]],
|
|
230
|
+
baseUrl: authContext.baseUrl || context.baseUrl || undefined,
|
|
231
|
+
teamId: authContext.teamId || context.teamId || undefined
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
const data = await resp.json();
|
|
235
|
+
if (!resp.ok) {
|
|
236
|
+
if (data.error === 'auth_required' || data.error === 'token_expired') {
|
|
237
|
+
updateAuthContext(data);
|
|
238
|
+
showAuthButton(true, data.error === 'token_expired' ? '重新连接 ONES' : '连接 ONES');
|
|
239
|
+
setAuthHint(`将打开一个浏览器窗口用于登录 ONES,完成后会自动重试。${authContext.baseUrl ? ` 当前站点:${authContext.baseUrl}` : ''}`);
|
|
240
|
+
}
|
|
241
|
+
const msg = data.error === 'token_expired'
|
|
242
|
+
? '登录状态已过期,请点击”连接 ONES”重新登录'
|
|
243
|
+
: data.error === 'auth_required'
|
|
244
|
+
? '当前还没有可用的 ONES 凭据,请先连接 ONES'
|
|
245
|
+
: (data.detail ?? data.error ?? '抓取失败');
|
|
246
|
+
setStatus(msg, true);
|
|
247
|
+
document.getElementById('tbody').innerHTML = `<tr><td colspan=”5” class=”empty”>${msg}</td></tr>`;
|
|
248
|
+
updateProgress(0, 0, '');
|
|
249
|
+
return;
|
|
186
250
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
? '当前还没有可用的 ONES 凭据,请先连接 ONES'
|
|
191
|
-
: (data.detail ?? data.error ?? '抓取失败');
|
|
192
|
-
setStatus(msg, true);
|
|
193
|
-
document.getElementById('tbody').innerHTML = `<tr><td colspan="5" class="empty">${msg}</td></tr>`;
|
|
194
|
-
return;
|
|
251
|
+
|
|
252
|
+
if (data.roots) roots.push(...data.roots);
|
|
253
|
+
if (data.tasks) allTasks.push(...data.tasks);
|
|
195
254
|
}
|
|
255
|
+
|
|
196
256
|
showAuthButton(false);
|
|
197
|
-
updateAuthContext(
|
|
198
|
-
allTasks =
|
|
257
|
+
updateAuthContext({ baseUrl: authContext.baseUrl, teamId: authContext.teamId });
|
|
258
|
+
allTasks = allTasks;
|
|
199
259
|
collapsed = new Set();
|
|
200
260
|
renderTable();
|
|
201
261
|
setStatus(`共获取 ${allTasks.length} 条任务`);
|
|
262
|
+
updateProgress(0, 0, '');
|
|
202
263
|
document.getElementById('countLabel').textContent = `共 ${allTasks.length} 条`;
|
|
203
264
|
} catch (e) {
|
|
204
265
|
setStatus('网络错误:' + e.message, true);
|
|
266
|
+
updateProgress(0, 0, '');
|
|
205
267
|
} finally {
|
|
206
268
|
document.getElementById('parseBtn').disabled = false;
|
|
207
269
|
}
|
|
@@ -365,6 +427,7 @@ function clearAll() {
|
|
|
365
427
|
document.getElementById('status').textContent = '';
|
|
366
428
|
setAuthHint('');
|
|
367
429
|
showAuthButton(false);
|
|
430
|
+
updateProgress(0, 0, '');
|
|
368
431
|
authContext = { baseUrl: '', teamId: '' };
|
|
369
432
|
document.getElementById('resultsSection').style.display = 'none';
|
|
370
433
|
allTasks = [];
|
package/public/launcher.vbs
CHANGED
|
@@ -1,34 +1,15 @@
|
|
|
1
1
|
Set WshShell = CreateObject("WScript.Shell")
|
|
2
2
|
Set fso = CreateObject("Scripting.FileSystemObject")
|
|
3
3
|
|
|
4
|
-
'
|
|
4
|
+
' 获取项目根目录
|
|
5
5
|
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
|
|
6
|
-
|
|
7
|
-
' 检查 Node.js 是否安装
|
|
8
|
-
On Error Resume Next
|
|
9
|
-
WshShell.Run "node --version", 0, True
|
|
10
|
-
If Err.Number <> 0 Then
|
|
11
|
-
MsgBox "未检测到 Node.js,请先安装 Node.js 18 或更高版本。" & vbCrLf & vbCrLf & "下载地址:https://nodejs.org/", vbCritical, "ONES 采集工具"
|
|
12
|
-
WScript.Quit
|
|
13
|
-
End If
|
|
14
|
-
On Error Goto 0
|
|
15
|
-
|
|
16
|
-
' 检查是否已安装依赖
|
|
17
|
-
If Not fso.FolderExists(scriptDir & "\node_modules") Then
|
|
18
|
-
result = MsgBox("首次运行需要安装依赖(约 30MB),是否继续?", vbYesNo + vbQuestion, "ONES 采集工具")
|
|
19
|
-
If result = vbNo Then
|
|
20
|
-
WScript.Quit
|
|
21
|
-
End If
|
|
22
|
-
|
|
23
|
-
' 显示安装窗口
|
|
24
|
-
WshShell.Run "cmd /c cd /d """ & scriptDir & """ && npm install && pause", 1, True
|
|
25
|
-
End If
|
|
6
|
+
projectRoot = fso.GetParentFolderName(scriptDir)
|
|
26
7
|
|
|
27
8
|
' 启动服务器(后台运行)
|
|
28
|
-
WshShell.Run "cmd /c cd /d """ &
|
|
9
|
+
WshShell.Run "cmd /c cd /d """ & projectRoot & """ && node src/server.mjs", 0, False
|
|
29
10
|
|
|
30
11
|
' 等待服务器启动
|
|
31
|
-
WScript.Sleep
|
|
12
|
+
WScript.Sleep 1500
|
|
32
13
|
|
|
33
14
|
' 打开浏览器
|
|
34
15
|
WshShell.Run "http://localhost:3000", 1
|
package/src/server.mjs
CHANGED
|
@@ -365,6 +365,21 @@ async function handleCrawl(req, res) {
|
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
// Heartbeat tracking
|
|
369
|
+
let lastHeartbeat = Date.now();
|
|
370
|
+
const HEARTBEAT_TIMEOUT = 2 * 60 * 1000; // 2 minutes
|
|
371
|
+
const HEARTBEAT_CHECK_INTERVAL = 30 * 1000; // 30 seconds
|
|
372
|
+
|
|
373
|
+
function startHeartbeatMonitor() {
|
|
374
|
+
setInterval(() => {
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
if (now - lastHeartbeat > HEARTBEAT_TIMEOUT) {
|
|
377
|
+
process.stdout.write('No heartbeat received for 5 minutes, shutting down server...\n');
|
|
378
|
+
process.exit(0);
|
|
379
|
+
}
|
|
380
|
+
}, HEARTBEAT_CHECK_INTERVAL);
|
|
381
|
+
}
|
|
382
|
+
|
|
368
383
|
const server = http.createServer(async (req, res) => {
|
|
369
384
|
if (req.method === 'GET' && req.url === '/') {
|
|
370
385
|
try {
|
|
@@ -376,6 +391,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
376
391
|
}
|
|
377
392
|
return;
|
|
378
393
|
}
|
|
394
|
+
if (req.method === 'GET' && req.url === '/api/heartbeat') {
|
|
395
|
+
lastHeartbeat = Date.now();
|
|
396
|
+
res.writeHead(204);
|
|
397
|
+
res.end();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
379
400
|
if (req.method === 'GET' && req.url === '/api/auth/status') {
|
|
380
401
|
return handleAuthStatus(req, res);
|
|
381
402
|
}
|
|
@@ -392,4 +413,16 @@ server.listen(PORT, () => {
|
|
|
392
413
|
const url = `http://localhost:${PORT}`;
|
|
393
414
|
process.stdout.write(`ONES Fetch Web UI → ${url}\n`);
|
|
394
415
|
openBrowser(url);
|
|
416
|
+
startHeartbeatMonitor();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
server.on('error', (err) => {
|
|
420
|
+
if (err.code === 'EADDRINUSE') {
|
|
421
|
+
process.stdout.write(`Port ${PORT} is already in use. Opening browser to existing instance...\n`);
|
|
422
|
+
openBrowser(`http://localhost:${PORT}`);
|
|
423
|
+
process.exit(0);
|
|
424
|
+
} else {
|
|
425
|
+
process.stderr.write(`Server error: ${err.message}\n`);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
395
428
|
});
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(npm install:*)",
|
|
5
|
-
"Bash(node:*)",
|
|
6
|
-
"Bash(timeout 3 npm start)",
|
|
7
|
-
"Bash(ls:*)",
|
|
8
|
-
"Bash(python process_icon.py)",
|
|
9
|
-
"Bash(python -c \"from PIL import Image; img = Image.open\\('public/icon.png'\\); print\\(f'尺寸: {img.size}, 模式: {img.mode}'\\)\")",
|
|
10
|
-
"Bash(git add:*)",
|
|
11
|
-
"Bash(git reset:*)",
|
|
12
|
-
"Bash(git mv:*)",
|
|
13
|
-
"Bash(gh --version)",
|
|
14
|
-
"Bash(gh repo:*)",
|
|
15
|
-
"Bash(npm whoami:*)",
|
|
16
|
-
"Bash(npm login:*)"
|
|
17
|
-
]
|
|
18
|
-
}
|
|
19
|
-
}
|
package/public/icon.png
DELETED
|
Binary file
|