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 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.png');
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.0.0",
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": ["ones", "task", "crawler", "project-management"],
17
+ "keywords": [
18
+ "ones",
19
+ "task",
20
+ "crawler",
21
+ "project-management"
22
+ ],
17
23
  "author": "",
18
24
  "license": "MIT"
19
25
  }
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
- const resp = await fetch('/api/crawl', {
176
- method: 'POST',
177
- headers: { 'Content-Type': 'application/json' },
178
- body: JSON.stringify({ taskIds, baseUrl: authContext.baseUrl || context.baseUrl || undefined, teamId: authContext.teamId || context.teamId || undefined }),
179
- });
180
- const data = await resp.json();
181
- if (!resp.ok) {
182
- if (data.error === 'auth_required' || data.error === 'token_expired') {
183
- updateAuthContext(data);
184
- showAuthButton(true, data.error === 'token_expired' ? '重新连接 ONES' : '连接 ONES');
185
- setAuthHint(`将打开一个浏览器窗口用于登录 ONES,完成后会自动重试。${authContext.baseUrl ? ` 当前站点:${authContext.baseUrl}` : ''}`);
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
- const msg = data.error === 'token_expired'
188
- ? '登录状态已过期,请点击“连接 ONES”重新登录'
189
- : data.error === 'auth_required'
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(data);
198
- allTasks = data.tasks ?? [];
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 = [];
@@ -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 """ & scriptDir & """ && node src/server.mjs", 0, False
9
+ WshShell.Run "cmd /c cd /d """ & projectRoot & """ && node src/server.mjs", 0, False
29
10
 
30
11
  ' 等待服务器启动
31
- WScript.Sleep 2000
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