1import { Actor } from 'apify';
2import { io } from 'socket.io-client';
3import { randomUUID } from 'node:crypto';
4
5const BACKEND_URL = 'https://console-backend.apify.com';
6const PUBLICATION = 'userOwnedPublicActorsIssuesPub';
7const KV_STORE_NAME = 'apify-console-open-issues';
8const SEEN_IDS_KEY = 'seenIssueIds';
9const FIRST_RUN_COMPLETE_KEY = 'firstRunComplete';
10const PAGE_LIMIT = 50;
11const SOCKET_TIMEOUT_MS = 30_000;
12
13function cookiesToHeader(cookies) {
14 return Object.entries(cookies)
15 .filter(([, value]) => value)
16 .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
17 .join('; ');
18}
19
20async function fetchUiToken(cookieHeader) {
21 const response = await fetch(`${BACKEND_URL}/public/authentication/resume`, {
22 method: 'POST',
23 headers: {
24 accept: 'application/json, text/plain, */*',
25 'content-type': 'application/json',
26 cookie: cookieHeader,
27 'x-idempotency-key': randomUUID(),
28 Referer: 'https://console.apify.com/',
29 },
30 body: JSON.stringify({
31 viewedUserId: '',
32 forceAdminToken: false,
33 }),
34 });
35 if (!response.ok) {
36 throw new Error(`resume failed (${response.status}): ${await response.text()}`);
37 }
38
39 const json = await response.json();
40 if (!json.uiToken) {
41 throw new Error('resume response missing uiToken');
42 }
43 return json.uiToken;
44}
45
46function subscribePage(uiToken, page) {
47 return new Promise((resolve, reject) => {
48 const socket = io(BACKEND_URL, {
49 path: '/socket.io/',
50 transports: ['websocket'],
51 });
52
53 const timer = setTimeout(() => {
54 socket.close();
55 reject(new Error(`Socket timeout on page ${page}`));
56 }, SOCKET_TIMEOUT_MS);
57
58 const cleanup = (err, value) => {
59 clearTimeout(timer);
60 socket.close();
61 if (err) reject(err);
62 else resolve(value);
63 };
64
65 socket.on('exception', (err) => {
66 cleanup(new Error(err.errorMessage ?? JSON.stringify(err)));
67 });
68
69 socket.on('connect_error', (err) => {
70 cleanup(err);
71 });
72
73 socket.on('connect', () => {
74 socket.emit(
75 'subscribe',
76 {
77 publicationName: PUBLICATION,
78 uiToken,
79 params: [{ page, limit: PAGE_LIMIT }, {}, {}],
80 messageId: randomUUID(),
81 },
82 (ack) => {
83 const result = Array.isArray(ack) ? ack[0] : ack;
84 if (!result || result.status !== 'OK') {
85 cleanup(new Error(`subscribe failed: ${JSON.stringify(result ?? ack)}`));
86 return;
87 }
88 cleanup(null, result.data);
89 },
90 );
91 });
92 });
93}
94
95async function fetchAllOpenIssues(uiToken) {
96 const openIssues = [];
97 let page = 0;
98 let hasNextPage = true;
99
100 while (hasNextPage) {
101 const { data, pagination } = await subscribePage(uiToken, page);
102 openIssues.push(...data.filter((issue) => issue.status === 'OPEN'));
103 hasNextPage = pagination?.hasNextPage === true;
104 page += 1;
105 }
106
107 return openIssues;
108}
109
110function consoleBackendHeaders(uiToken, cookieHeader) {
111 return {
112 accept: 'application/json, text/plain, */*',
113 authorization: `Bearer ${uiToken}`,
114 ...(cookieHeader ? { cookie: cookieHeader } : {}),
115 Referer: 'https://console.apify.com/',
116 };
117}
118
119function stripHtml(html) {
120 if (!html || typeof html !== 'string') {
121 return '';
122 }
123 return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
124}
125
126function normalizeCommentsList(payload) {
127 if (Array.isArray(payload)) {
128 return payload;
129 }
130 if (Array.isArray(payload?.data)) {
131 return payload.data;
132 }
133 if (Array.isArray(payload?.items)) {
134 return payload.items;
135 }
136 return [];
137}
138
139function pickLastCommentText(comments) {
140 if (!comments.length) {
141 return null;
142 }
143
144 const comment = comments.at(-1);
145 const rawText = comment.text ?? comment.body ?? comment.content ?? comment.message ?? '';
146 const text = stripHtml(typeof rawText === 'string' ? rawText : '');
147 return text || null;
148}
149
150async function fetchIssueComments(issueId, uiToken, cookieHeader) {
151 const response = await fetch(
152 `${BACKEND_URL}/actor-issues/${issueId}/comments`,
153 { headers: consoleBackendHeaders(uiToken, cookieHeader) },
154 );
155
156 if (!response.ok) {
157 throw new Error(`HTTP ${response.status}`);
158 }
159
160 return response.json();
161}
162
163async function fetchLastCommentText(issueId, uiToken, cookieHeader) {
164 try {
165 const payload = await fetchIssueComments(issueId, uiToken, cookieHeader);
166 return pickLastCommentText(normalizeCommentsList(payload));
167 } catch (err) {
168 console.warn(`Could not load comments for ${issueId}:`, err.message);
169 return null;
170 }
171}
172
173function issueConsoleUrl(issue) {
174 const actorId = issue.actor._id;
175 return `https://console.apify.com/actors/${actorId}/issues/${issue._id}`;
176}
177
178async function buildNewIssuePayload(issue, uiToken, cookieHeader) {
179 const actorTitle = issue.actor.title;
180 const reporter = issue.publicAuthorInfo.displayName;
181 const url = issueConsoleUrl(issue);
182 const lastComment = await fetchLastCommentText(issue._id, uiToken, cookieHeader);
183
184 return {
185 type: 'new_open_issue',
186 notifiedAt: new Date().toISOString(),
187 issueId: issue._id,
188 title: issue.title,
189 status: issue.status,
190 objectType: issue.objectType,
191 objectId: issue.objectId,
192 createdAt: issue.createdAt,
193 url,
194 actorTitle,
195 reporter,
196 lastComment,
197 };
198}
199
200function buildFailurePayload(step, err) {
201 return {
202 type: 'fetch_failure',
203 notifiedAt: new Date().toISOString(),
204 step,
205 errorMessage: err?.message ?? String(err),
206 };
207}
208
209async function notifyFailure(step, err) {
210 console.error(`[${step}]`, err?.message ?? err);
211 try {
212 await Actor.pushData(buildFailurePayload(step, err));
213 } catch (pushErr) {
214 console.error('Could not push failure to dataset:', pushErr.message);
215 }
216}
217
218async function syncOpenIssues(store, openIssues, uiToken, cookieHeader) {
219 const openIds = openIssues.map((issue) => issue._id);
220 const firstRunComplete = await store.getValue(FIRST_RUN_COMPLETE_KEY);
221
222 if (!firstRunComplete) {
223 await store.setValue(SEEN_IDS_KEY, openIds);
224 await store.setValue(FIRST_RUN_COMPLETE_KEY, true);
225 console.log(
226 `First run: recorded ${openIds.length} open issue(s) in KV; no dataset push until next run.`,
227 );
228 return;
229 }
230
231 const previousSeen = new Set((await store.getValue(SEEN_IDS_KEY)) ?? []);
232
233 const newIssueRecords = [];
234 for (const issue of openIssues) {
235 if (previousSeen.has(issue._id)) {
236 continue;
237 }
238 newIssueRecords.push(await buildNewIssuePayload(issue, uiToken, cookieHeader));
239 }
240
241 if (newIssueRecords.length > 0) {
242 await Actor.pushData(newIssueRecords);
243 }
244
245
246 await store.setValue(SEEN_IDS_KEY, openIds);
247
248 const openIdSet = new Set(openIds);
249 const removedClosed = [...previousSeen].filter((id) => !openIdSet.has(id)).length;
250 console.log(
251 `Open: ${openIds.length}, new notified: ${newIssueRecords.length}, `
252 + `kv ids: ${openIds.length}, removed closed: ${removedClosed}`,
253 );
254}
255
256await Actor.init();
257
258const input = (await Actor.getInput()) ?? {};
259const apifyCookies = {
260 ApifyRT: input.apifyRt,
261 ApifyProdUserId: '',
262 ApifyAcqRef: '',
263};
264const cookieHeader = cookiesToHeader(apifyCookies);
265
266try {
267 if (!apifyCookies.ApifyRT) {
268 throw new Error('Set apifyRt in actor input');
269 }
270
271 let uiToken;
272 try {
273 uiToken = await fetchUiToken(cookieHeader);
274 } catch (err) {
275 await notifyFailure('authentication/resume', err);
276 await Actor.exit({ exitCode: 1 });
277 }
278
279 let openIssues;
280 try {
281 openIssues = await fetchAllOpenIssues(uiToken);
282 } catch (err) {
283 await notifyFailure('socket.io subscribe', err);
284 await Actor.exit({ exitCode: 1 });
285 }
286
287 const store = await Actor.openKeyValueStore(KV_STORE_NAME);
288 try {
289 await syncOpenIssues(store, openIssues, uiToken, cookieHeader);
290 } catch (err) {
291 await notifyFailure('sync / notify', err);
292 await Actor.exit({ exitCode: 1 });
293 }
294} catch (err) {
295 await notifyFailure('run', err);
296 await Actor.exit({ exitCode: 1 });
297}
298
299await Actor.exit();