jacky-creator 0.1.0-beta.6

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.
@@ -0,0 +1,523 @@
1
+ // Ego Lite script. Run: ego-browser nodejs < scripts/collect-publish.mjs
2
+ // Optional: OIL_COLLECT_PLATFORMS, OIL_COLLECT_TARGETS, OIL_COLLECT_SPACE,
3
+ // OIL_COLLECT_KEEP, OIL_COLLECT_CLEANUP_STALE, OIL_COLLECT_CLEANUP_NAMES,
4
+ // OIL_COLLECT_CLEANUP_PREFIXES, OIL_COLLECT_MAX_PAGES, OIL_COLLECT_XHS_SCROLL.
5
+ // Each run uses a new oil-collect-* space and closes it when finished.
6
+ const PAGES = [
7
+ { platform: "xiaohongshu", url: "https://creator.xiaohongshu.com/new/note-manager" },
8
+ { platform: "douyin", url: "https://creator.douyin.com/creator-micro/content/manage" },
9
+ { platform: "bilibili", url: "https://member.bilibili.com/platform/upload-manager/article" },
10
+ { platform: "wechat", url: "https://channels.weixin.qq.com/platform/post/list" },
11
+ ];
12
+
13
+ function envText(name, fallback) {
14
+ if (typeof globalThis[name] === "string" && globalThis[name] !== "") return globalThis[name];
15
+ const value = process.env[name];
16
+ return value === undefined || value === "" ? fallback : value;
17
+ }
18
+
19
+ const MAX_PAGES = Math.max(1, Number(envText("OIL_COLLECT_MAX_PAGES", "80")) || 80);
20
+ const XHS_SCROLL_STEPS = Math.max(1, Number(envText("OIL_COLLECT_XHS_SCROLL", "80")) || 80);
21
+
22
+ const wanted = String(
23
+ typeof OIL_COLLECT_PLATFORMS === "string" ? OIL_COLLECT_PLATFORMS : (process.env.OIL_COLLECT_PLATFORMS ?? ""),
24
+ )
25
+ .split(",")
26
+ .map((item) => item.trim())
27
+ .filter(Boolean);
28
+ const pages = wanted.length === 0 ? PAGES : PAGES.filter((page) => wanted.includes(page.platform));
29
+
30
+ function parseTargets(raw) {
31
+ if (Array.isArray(raw)) return raw.filter((item) => item && typeof item.title === "string");
32
+ if (typeof raw !== "string" || raw.trim() === "") return [];
33
+ try {
34
+ const parsed = JSON.parse(raw);
35
+ return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.title === "string") : [];
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ const targets = parseTargets(
42
+ typeof OIL_COLLECT_TARGETS !== "undefined" ? OIL_COLLECT_TARGETS : process.env.OIL_COLLECT_TARGETS,
43
+ );
44
+
45
+ function normalizeTitle(value) {
46
+ return String(value || "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
47
+ }
48
+
49
+ function titleScore(local, remote) {
50
+ const left = normalizeTitle(local);
51
+ const right = normalizeTitle(remote);
52
+ if (left === "" || right === "") return 0;
53
+ if (left === right) return 1;
54
+ if (left.includes(right) || right.includes(left)) return 0.88;
55
+ const shorter = left.length < right.length ? left : right;
56
+ const longer = left.length < right.length ? right : left;
57
+ let hits = 0;
58
+ const size = Math.min(4, shorter.length);
59
+ if (size < 2) return 0;
60
+ for (let index = 0; index <= shorter.length - size; index += 1) {
61
+ if (longer.includes(shorter.slice(index, index + size))) hits += 1;
62
+ }
63
+ const possible = shorter.length - size + 1;
64
+ return possible === 0 ? 0 : hits / possible * 0.7;
65
+ }
66
+
67
+ function hitsTarget(item) {
68
+ if (targets.length === 0) return false;
69
+ return targets.some((target) => {
70
+ const remoteIds = Array.isArray(target.remoteIds) ? target.remoteIds : [];
71
+ const urls = Array.isArray(target.urls) ? target.urls : [];
72
+ if (item.remoteId && remoteIds.includes(item.remoteId)) return true;
73
+ if (item.url && urls.includes(item.url)) return true;
74
+ return titleScore(target.title, item.title) >= 0.85;
75
+ });
76
+ }
77
+
78
+ function foundTargets(items) {
79
+ return targets.length > 0 && (items || []).some(hitsTarget);
80
+ }
81
+
82
+ const HOOK = `(() => {
83
+ if (window.__oilCollectHook) return;
84
+ window.__oilCollectHook = true;
85
+ window.__OIL_COLLECT__ = [];
86
+ const push = (url, text) => {
87
+ window.__OIL_COLLECT__.push({ url: String(url), text: String(text || "").slice(0, 900000) });
88
+ };
89
+ const origFetch = window.fetch;
90
+ window.fetch = async function (...args) {
91
+ const res = await origFetch.apply(this, args);
92
+ try {
93
+ const req = args[0];
94
+ const url = typeof req === "string" ? req : (req && req.url) || "";
95
+ push(url, await res.clone().text());
96
+ } catch {}
97
+ return res;
98
+ };
99
+ const origOpen = XMLHttpRequest.prototype.open;
100
+ const origSend = XMLHttpRequest.prototype.send;
101
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
102
+ this.__oilUrl = url;
103
+ return origOpen.call(this, method, url, ...rest);
104
+ };
105
+ XMLHttpRequest.prototype.send = function (...args) {
106
+ this.addEventListener("load", function () {
107
+ try { push(this.__oilUrl, this.responseText); } catch {}
108
+ });
109
+ return origSend.apply(this, args);
110
+ };
111
+ })()`;
112
+
113
+ function firstLine(value) {
114
+ return String(value || "").split(/\n/)[0].trim();
115
+ }
116
+
117
+ function num(value) {
118
+ const n = Number(value);
119
+ return Number.isFinite(n) ? n : undefined;
120
+ }
121
+
122
+ function timestamp(value) {
123
+ const n = num(value);
124
+ if (n === undefined || n <= 0) return undefined;
125
+ return n < 1_000_000_000_000 ? Math.round(n * 1000) : Math.round(n);
126
+ }
127
+
128
+ function dedupe(items) {
129
+ const seen = new Set();
130
+ const out = [];
131
+ for (const item of items) {
132
+ const key = item.remoteId || item.url || item.title;
133
+ if (seen.has(key)) continue;
134
+ seen.add(key);
135
+ out.push(item);
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function parseDouyinPayload(payload) {
141
+ const list = payload?.aweme_list;
142
+ if (!Array.isArray(list)) return [];
143
+ return list.flatMap((aweme) => {
144
+ const title = firstLine(aweme.item_title || aweme.desc || "");
145
+ if (!title) return [];
146
+ const id = aweme.aweme_id ? String(aweme.aweme_id) : "";
147
+ const stats = aweme.statistics || {};
148
+ const item = { title };
149
+ if (id) item.remoteId = id;
150
+ if (typeof aweme.share_url === "string" && aweme.share_url.startsWith("http")) item.url = aweme.share_url;
151
+ else if (id) item.url = `https://www.douyin.com/video/${id}`;
152
+ const views = num(stats.play_count);
153
+ const likes = num(stats.digg_count);
154
+ const comments = num(stats.comment_count);
155
+ const publishedAt = timestamp(aweme.create_time || aweme.publish_time);
156
+ if (views !== undefined) item.views = views;
157
+ if (likes !== undefined) item.likes = likes;
158
+ if (comments !== undefined) item.comments = comments;
159
+ if (publishedAt !== undefined) item.publishedAt = publishedAt;
160
+ return [item];
161
+ });
162
+ }
163
+
164
+ function parseBiliPayload(payload) {
165
+ const list = payload?.data?.arc_audits;
166
+ if (!Array.isArray(list)) return [];
167
+ return list.flatMap((row) => {
168
+ const arc = row.Archive || {};
169
+ const title = firstLine(arc.title);
170
+ if (!title) return [];
171
+ const bvid = arc.bvid ? String(arc.bvid) : "";
172
+ const stat = row.stat || {};
173
+ const item = { title };
174
+ if (bvid) {
175
+ item.remoteId = bvid;
176
+ item.url = `https://www.bilibili.com/video/${bvid}`;
177
+ }
178
+ if (Number.isFinite(Number(stat.view))) item.views = Number(stat.view);
179
+ if (Number.isFinite(Number(stat.like))) item.likes = Number(stat.like);
180
+ if (Number.isFinite(Number(stat.reply))) item.comments = Number(stat.reply);
181
+ const publishedAt = timestamp(arc.pubdate || arc.ctime || row.pubtime);
182
+ if (publishedAt !== undefined) item.publishedAt = publishedAt;
183
+ return [item];
184
+ });
185
+ }
186
+
187
+ function parseWechatPayload(payload) {
188
+ const list = payload?.data?.list;
189
+ if (!Array.isArray(list)) return [];
190
+ return list.flatMap((row) => {
191
+ const title = firstLine(row?.desc?.description) || "未填写标题";
192
+ const id = row.objectId ? String(row.objectId) : "";
193
+ const item = { title };
194
+ if (id) {
195
+ item.remoteId = id;
196
+ item.url = "https://channels.weixin.qq.com/platform/post/list";
197
+ }
198
+ if (Number.isFinite(Number(row.readCount))) item.views = Number(row.readCount);
199
+ if (Number.isFinite(Number(row.likeCount))) item.likes = Number(row.likeCount);
200
+ if (Number.isFinite(Number(row.commentCount))) item.comments = Number(row.commentCount);
201
+ const publishedAt = timestamp(row.createTime || row.create_time || row.objectCreateTime || row.publishTime);
202
+ if (publishedAt !== undefined) item.publishedAt = publishedAt;
203
+ return [item];
204
+ });
205
+ }
206
+
207
+ async function pageJson(expression) {
208
+ const answer = await cdp("Runtime.evaluate", {
209
+ expression,
210
+ awaitPromise: true,
211
+ returnByValue: true,
212
+ });
213
+ if (answer?.exceptionDetails) {
214
+ const detail = answer.exceptionDetails.exception?.description || answer.exceptionDetails.text;
215
+ throw new Error(detail || "evaluate failed");
216
+ }
217
+ return answer?.result?.value;
218
+ }
219
+
220
+ async function activateWechat() {
221
+ await cdp("Page.bringToFront", {});
222
+ await cdp("Page.setWebLifecycleState", { state: "active" });
223
+ await cdp("Emulation.setFocusEmulationEnabled", { enabled: true });
224
+ }
225
+
226
+ async function hookTab() {
227
+ await cdp("Page.addScriptToEvaluateOnNewDocument", { source: HOOK });
228
+ await js(HOOK);
229
+ }
230
+
231
+ function pageLooksLoggedOut(text, href) {
232
+ const body = String(text || "");
233
+ const login = /登录|掃碼|扫码登录|请先登录|尚未登录/.test(body)
234
+ && !/作品管理|笔记管理|已发布|稿件管理|发表记录|视频管理|内容管理/.test(body);
235
+ return login || /login\.html/.test(String(href || ""));
236
+ }
237
+
238
+ async function xhsState() {
239
+ return js(String.raw`(() => {
240
+ const rows = window.__OIL_COLLECT__ || [];
241
+ const byId = new Map();
242
+ let total = 0;
243
+ for (const row of rows) {
244
+ if (!String(row.url).includes("/creator/note/user/posted")) continue;
245
+ let json;
246
+ try { json = JSON.parse(row.text); } catch { continue; }
247
+ const notes = json && json.data && json.data.notes;
248
+ if (Array.isArray(notes)) {
249
+ for (const note of notes) {
250
+ const title = String(note.display_title || note.title || "").split(/\n/)[0].trim();
251
+ if (!title) continue;
252
+ const id = note.id ? String(note.id) : title;
253
+ const token = note.xsec_token ? String(note.xsec_token) : "";
254
+ const item = { title };
255
+ if (note.id) {
256
+ item.remoteId = String(note.id);
257
+ item.url = token
258
+ ? "https://www.xiaohongshu.com/explore/" + note.id + "?xsec_token=" + encodeURIComponent(token)
259
+ : "https://www.xiaohongshu.com/explore/" + note.id;
260
+ }
261
+ const views = Number(note.view_count);
262
+ const likes = Number(note.likes);
263
+ const comments = Number(note.comments_count);
264
+ const rawPublishedAt = Number(note.time || note.create_time || note.publish_time);
265
+ if (Number.isFinite(views)) item.views = views;
266
+ if (Number.isFinite(likes)) item.likes = likes;
267
+ if (Number.isFinite(comments)) item.comments = comments;
268
+ if (Number.isFinite(rawPublishedAt) && rawPublishedAt > 0) item.publishedAt = rawPublishedAt < 1000000000000 ? Math.round(rawPublishedAt * 1000) : Math.round(rawPublishedAt);
269
+ byId.set(id, item);
270
+ }
271
+ }
272
+ const tags = json && json.data && json.data.tags;
273
+ const checked = Array.isArray(tags) ? tags.find((tag) => tag && tag.checked) : undefined;
274
+ if (checked && Number.isFinite(Number(checked.notes_count))) total = Number(checked.notes_count);
275
+ }
276
+ const text = (document.body && document.body.innerText) || "";
277
+ const login = /登录|掃碼|扫码登录|请先登录|尚未登录/.test(text)
278
+ && !/作品管理|笔记管理|已发布|稿件管理|发表记录|视频管理|内容管理/.test(text);
279
+ return {
280
+ items: [...byId.values()],
281
+ n: byId.size,
282
+ total,
283
+ loginRequired: byId.size === 0 && login,
284
+ loading: /正在加载中/.test(text),
285
+ };
286
+ })()`);
287
+ }
288
+
289
+ async function scrollXhsList() {
290
+ await js(String.raw`(() => {
291
+ const el = [...document.querySelectorAll("*")].filter((node) => {
292
+ const style = getComputedStyle(node);
293
+ return (style.overflowY === "auto" || style.overflowY === "scroll")
294
+ && node.scrollHeight > node.clientHeight + 80;
295
+ }).sort((a, b) => b.scrollHeight - a.scrollHeight)[0];
296
+ if (el) el.scrollTop = el.scrollHeight;
297
+ else window.scrollTo(0, document.documentElement.scrollHeight);
298
+ })()`);
299
+ try { await scroll({ dy: 1800 }); } catch { /* page may ignore wheel */ }
300
+ }
301
+
302
+ async function collectXiaohongshu(url) {
303
+ await hookTab();
304
+ let state = await xhsState();
305
+ if (state.n === 0) {
306
+ await gotoAndWait(url, { timeout: 40, settle: 2 });
307
+ await hookTab();
308
+ const started = Date.now();
309
+ while (Date.now() - started < 12_000) {
310
+ state = await xhsState();
311
+ if (state.loginRequired || state.n > 0) break;
312
+ await wait(1);
313
+ }
314
+ }
315
+ if (state.loginRequired) return { items: [], loginRequired: true };
316
+
317
+ let last = state.n;
318
+ let stall = 0;
319
+ for (let step = 0; step < XHS_SCROLL_STEPS; step += 1) {
320
+ if (foundTargets(state.items)) break;
321
+ if (state.total > 0 && state.n >= state.total && !state.loading) break;
322
+ await scrollXhsList();
323
+ await wait(1.1);
324
+ state = await xhsState();
325
+ if (state.n <= last) {
326
+ stall += 1;
327
+ if (stall >= 5 && !state.loading) break;
328
+ } else {
329
+ stall = 0;
330
+ last = state.n;
331
+ }
332
+ }
333
+ return { items: dedupe(state.items || []), loginRequired: false };
334
+ }
335
+
336
+ async function collectDouyin() {
337
+ const items = [];
338
+ let cursor = 0;
339
+ let total = Number.POSITIVE_INFINITY;
340
+ for (let page = 0; page < MAX_PAGES; page += 1) {
341
+ const payload = await pageJson(`fetch("/janus/douyin/creator/pc/work_list?status=0&count=20&max_cursor=${cursor}&scene=star_atlas&device_platform=android&aid=1128", {
342
+ credentials: "include"
343
+ }).then(async (r) => ({ http: r.status, json: await r.json() }))`);
344
+ const json = payload?.json;
345
+ if (payload?.http && payload.http >= 400) break;
346
+ const batch = parseDouyinPayload(json);
347
+ items.push(...batch);
348
+ if (foundTargets(items)) break;
349
+ if (typeof json?.total === "number") total = json.total;
350
+ const hasMore = json?.has_more === true || json?.has_more === 1;
351
+ const next = Number(json?.max_cursor);
352
+ if (batch.length === 0 || !hasMore || items.length >= total) break;
353
+ if (!Number.isFinite(next) || next === cursor) break;
354
+ cursor = next;
355
+ }
356
+ if (items.length > 0) return { items: dedupe(items), loginRequired: false };
357
+
358
+ await hookTab();
359
+ const started = Date.now();
360
+ while (Date.now() - started < 12_000) {
361
+ const hooked = await js(String.raw`(() => {
362
+ const rows = (window.__OIL_COLLECT__ || []).filter((row) => String(row.url).includes("/work_list"));
363
+ const text = (document.body && document.body.innerText) || "";
364
+ const login = /登录|掃碼|扫码登录|请先登录|尚未登录/.test(text)
365
+ && !/作品管理|笔记管理|已发布|稿件管理|发表记录|视频管理|内容管理/.test(text);
366
+ const byId = new Map();
367
+ for (const row of rows) {
368
+ let json;
369
+ try { json = JSON.parse(row.text); } catch { continue; }
370
+ const list = json && json.aweme_list;
371
+ if (!Array.isArray(list)) continue;
372
+ for (const aweme of list) {
373
+ const title = String(aweme.item_title || aweme.desc || "").split(/\n/)[0].trim();
374
+ if (!title) continue;
375
+ const id = aweme.aweme_id ? String(aweme.aweme_id) : title;
376
+ const stats = aweme.statistics || {};
377
+ const item = { title };
378
+ if (aweme.aweme_id) item.remoteId = String(aweme.aweme_id);
379
+ if (typeof aweme.share_url === "string" && aweme.share_url.startsWith("http")) item.url = aweme.share_url;
380
+ else if (aweme.aweme_id) item.url = "https://www.douyin.com/video/" + aweme.aweme_id;
381
+ const views = Number(stats.play_count);
382
+ const likes = Number(stats.digg_count);
383
+ const comments = Number(stats.comment_count);
384
+ const rawPublishedAt = Number(aweme.create_time || aweme.publish_time);
385
+ if (Number.isFinite(views)) item.views = views;
386
+ if (Number.isFinite(likes)) item.likes = likes;
387
+ if (Number.isFinite(comments)) item.comments = comments;
388
+ if (Number.isFinite(rawPublishedAt) && rawPublishedAt > 0) item.publishedAt = rawPublishedAt < 1000000000000 ? Math.round(rawPublishedAt * 1000) : Math.round(rawPublishedAt);
389
+ byId.set(id, item);
390
+ }
391
+ }
392
+ return { items: [...byId.values()], loginRequired: byId.size === 0 && login };
393
+ })()`);
394
+ if (hooked?.loginRequired || (Array.isArray(hooked?.items) && hooked.items.length > 0)) return hooked;
395
+ await wait(1);
396
+ }
397
+ return { items: [], loginRequired: false };
398
+ }
399
+
400
+ async function collectBilibili() {
401
+ const items = [];
402
+ let expected = Number.POSITIVE_INFINITY;
403
+ for (let pn = 1; pn <= MAX_PAGES; pn += 1) {
404
+ const payload = await pageJson(`fetch("/x/web/archives?status=pubed&pn=${pn}&ps=30&coop=1&interactive=1", {
405
+ credentials: "include"
406
+ }).then((r) => r.json())`);
407
+ const batch = parseBiliPayload(payload);
408
+ items.push(...batch);
409
+ if (foundTargets(items)) break;
410
+ const count = payload?.data?.page?.count ?? payload?.data?.class?.pubed;
411
+ if (typeof count === "number") expected = count;
412
+ if (batch.length === 0 || items.length >= expected) break;
413
+ }
414
+ return { items: dedupe(items), loginRequired: false };
415
+ }
416
+
417
+ async function collectWechat() {
418
+ const items = [];
419
+ let expected = Number.POSITIVE_INFINITY;
420
+ for (let currentPage = 1; currentPage <= MAX_PAGES; currentPage += 1) {
421
+ const payload = await pageJson(`fetch("/cgi-bin/mmfinderassistant-bin/post/post_list", {
422
+ method: "POST",
423
+ credentials: "include",
424
+ headers: { "content-type": "application/json" },
425
+ body: JSON.stringify({ currentPage: ${currentPage}, pageSize: 20 })
426
+ }).then((r) => r.json())`);
427
+ const batch = parseWechatPayload(payload);
428
+ items.push(...batch);
429
+ if (foundTargets(items)) break;
430
+ if (typeof payload?.data?.totalCount === "number") expected = payload.data.totalCount;
431
+ const cont = payload?.data?.continueFlag;
432
+ if (batch.length === 0 || cont === false || items.length >= expected) break;
433
+ }
434
+ return { items: dedupe(items), loginRequired: false };
435
+ }
436
+
437
+ function csvNames(raw) {
438
+ return String(raw || "")
439
+ .split(",")
440
+ .map((item) => item.trim())
441
+ .filter(Boolean);
442
+ }
443
+
444
+ const keepSpace = envText("OIL_COLLECT_KEEP", "0") === "1";
445
+ const cleanupStale = envText("OIL_COLLECT_CLEANUP_STALE", "1") !== "0";
446
+ const spaceName = envText("OIL_COLLECT_SPACE", "") || `oil-collect-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
447
+ const leftoverNames = new Set(["oil-collect-publish", ...csvNames(envText("OIL_COLLECT_CLEANUP_NAMES", ""))]);
448
+ const leftoverPrefixes = csvNames(envText("OIL_COLLECT_CLEANUP_PREFIXES", ""));
449
+ const task = await useOrCreateTaskSpace(spaceName);
450
+ const collected = [];
451
+ let spaceClosed = keepSpace;
452
+ try {
453
+ for (const page of pages) {
454
+ try {
455
+ if (page.platform === "wechat") await activateWechat();
456
+ await openOrReuseTab(page.url, { wait: true, timeout: 35 });
457
+ if (page.platform === "wechat") await activateWechat();
458
+ await hookTab();
459
+
460
+ const text = await js(`(document.body && document.body.innerText) || ""`);
461
+ const href = await js(`location.href`);
462
+ if (pageLooksLoggedOut(text, href)) {
463
+ collected.push({ platform: page.platform, items: [], loginRequired: true });
464
+ continue;
465
+ }
466
+
467
+ let extracted = { items: [], loginRequired: false };
468
+ if (page.platform === "xiaohongshu") extracted = await collectXiaohongshu(page.url);
469
+ else if (page.platform === "douyin") extracted = await collectDouyin();
470
+ else if (page.platform === "bilibili") extracted = await collectBilibili();
471
+ else if (page.platform === "wechat") {
472
+ await activateWechat();
473
+ extracted = await collectWechat();
474
+ }
475
+
476
+ collected.push({
477
+ platform: page.platform,
478
+ items: Array.isArray(extracted?.items) ? extracted.items : [],
479
+ loginRequired: extracted?.loginRequired === true,
480
+ });
481
+ } catch (cause) {
482
+ collected.push({
483
+ platform: page.platform,
484
+ items: [],
485
+ error: cause instanceof Error ? cause.message : String(cause),
486
+ });
487
+ }
488
+ }
489
+ } finally {
490
+ if (!keepSpace) {
491
+ try {
492
+ await completeTaskSpace(task.id, { keep: false });
493
+ spaceClosed = true;
494
+ } catch {
495
+ spaceClosed = false;
496
+ }
497
+ }
498
+ if (cleanupStale && typeof listTaskSpaces === "function") {
499
+ try {
500
+ const spaces = await listTaskSpaces();
501
+ for (const space of spaces || []) {
502
+ const name = String(space.name || "");
503
+ if (space.ownership === "user") continue;
504
+ if (keepSpace && space.id === task.id) continue;
505
+ const current = !spaceClosed && (space.id === task.id || name === spaceName || name === task.name);
506
+ const named = leftoverNames.has(name);
507
+ const prefixed = leftoverPrefixes.some((prefix) => name === prefix || name.startsWith(prefix));
508
+ if (!current && !named && !prefixed) continue;
509
+ try {
510
+ await completeTaskSpace(space.id, { keep: false });
511
+ if (space.id === task.id) spaceClosed = true;
512
+ } catch { /* ignore stale close errors */ }
513
+ }
514
+ } catch { /* listing spaces is best-effort */ }
515
+ }
516
+ }
517
+ cliLog(JSON.stringify({
518
+ ok: true,
519
+ taskId: task.id,
520
+ taskSpace: task.name || spaceName,
521
+ collected,
522
+ spaceClosed,
523
+ }));