note.js
1 import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 const INSTAGRAM_INBOX_URL = 'https://www.instagram.com/direct/inbox/'; 4 const INSTAGRAM_NOTE_DOC_ID = '25155183657506484'; 5 const INSTAGRAM_NOTE_MUTATION_NAME = 'usePolarisCreateInboxTrayItemSubmitMutation'; 6 const INSTAGRAM_NOTE_ROOT_FIELD = 'xdt_create_inbox_tray_item'; 7 function requirePage(page) { 8 if (!page) 9 throw new CommandExecutionError('Browser session required for instagram note'); 10 return page; 11 } 12 function validateInstagramNoteArgs(kwargs) { 13 if (kwargs.content === undefined) { 14 throw new ArgumentError('Argument "content" is required.', 'Provide a note text, for example: opencli instagram note "hello"'); 15 } 16 } 17 function normalizeInstagramNoteContent(kwargs) { 18 const content = String(kwargs.content ?? '').trim(); 19 if (!content) { 20 throw new ArgumentError('Instagram note content cannot be empty.', 'Provide a non-empty note text, for example: opencli instagram note "hello"'); 21 } 22 if (Array.from(content).length > 60) { 23 throw new ArgumentError('Instagram note content must be 60 characters or fewer.', 'Shorten the note text and try again.'); 24 } 25 return content; 26 } 27 function buildNoteSuccessResult(noteId) { 28 return [{ 29 status: '✅ Posted', 30 detail: 'Instagram note published successfully', 31 noteId, 32 }]; 33 } 34 function buildPublishInstagramNoteJs(content) { 35 return ` 36 (async () => { 37 const input = ${JSON.stringify({ content })}; 38 const html = document.documentElement?.outerHTML || ''; 39 const scripts = Array.from(document.scripts || []) 40 .map((script) => script.textContent || '') 41 .join('\\n'); 42 const source = html + '\\n' + scripts; 43 const pick = (patterns) => { 44 for (const pattern of patterns) { 45 const match = source.match(pattern); 46 if (!match) continue; 47 for (let index = 1; index < match.length; index += 1) { 48 if (match[index]) return match[index]; 49 } 50 return match[0] || ''; 51 } 52 return ''; 53 }; 54 const readCookie = (name) => { 55 const prefix = name + '='; 56 const part = document.cookie 57 .split('; ') 58 .find((cookie) => cookie.startsWith(prefix)); 59 return part ? decodeURIComponent(part.slice(prefix.length)) : ''; 60 }; 61 const actorId = pick([ 62 /"actorID":"(\\d+)"/, 63 /"actor_id":"(\\d+)"/, 64 /"viewerId":"(\\d+)"/, 65 ]); 66 const fbDtsg = pick([ 67 /(NAF[a-zA-Z0-9:_-]{20,})/, 68 /(NAf[a-zA-Z0-9:_-]{20,})/, 69 ]); 70 const lsd = pick([ 71 /"LSD",\\[\\],\\{"token":"([^"]+)"\\}/, 72 /"lsd":"([^"]+)"/, 73 ]); 74 const appId = pick([ 75 /"X-IG-App-ID":"(\\d+)"/, 76 /"instagramWebAppId":"(\\d+)"/, 77 /"appId":"(\\d+)"/, 78 ]); 79 const asbdId = pick([ 80 /"X-ASBD-ID":"(\\d+)"/, 81 /"asbd_id":"(\\d+)"/, 82 ]); 83 const spinR = pick([/"__spin_r":(\\d+)/]); 84 const spinB = pick([/"__spin_b":"([^"]+)"/]); 85 const spinT = pick([/"__spin_t":(\\d+)/]); 86 const csrfToken = readCookie('csrftoken') || pick([ 87 /"csrf_token":"([^"]+)"/, 88 /"csrfToken":"([^"]+)"/, 89 ]); 90 const jazoest = fbDtsg 91 ? '2' + Array.from(fbDtsg).reduce((total, char) => total + char.charCodeAt(0), 0) 92 : ''; 93 94 if (!actorId || !fbDtsg || !lsd || !appId || !csrfToken || !spinR || !spinB || !spinT || !jazoest) { 95 return { 96 ok: false, 97 stage: 'config', 98 text: JSON.stringify({ 99 actorId: Boolean(actorId), 100 fbDtsg: Boolean(fbDtsg), 101 lsd: Boolean(lsd), 102 appId: Boolean(appId), 103 csrfToken: Boolean(csrfToken), 104 spinR: Boolean(spinR), 105 spinB: Boolean(spinB), 106 spinT: Boolean(spinT), 107 jazoest: Boolean(jazoest), 108 }), 109 }; 110 } 111 112 const variables = { 113 input: { 114 actor_id: actorId, 115 client_mutation_id: '1', 116 additional_params: { 117 note_create_params: { 118 note_style: 0, 119 text: input.content, 120 }, 121 }, 122 audience: 0, 123 inbox_tray_item_type: 'note', 124 }, 125 }; 126 127 const body = new URLSearchParams(); 128 body.set('av', actorId); 129 body.set('__user', '0'); 130 body.set('__a', '1'); 131 body.set('__req', '1'); 132 body.set('__hs', ''); 133 body.set('dpr', String(window.devicePixelRatio || 1)); 134 body.set('__ccg', 'UNKNOWN'); 135 body.set('__rev', spinR); 136 body.set('__s', ''); 137 body.set('__hsi', ''); 138 body.set('__dyn', ''); 139 body.set('__csr', ''); 140 body.set('__comet_req', '7'); 141 body.set('fb_dtsg', fbDtsg); 142 body.set('jazoest', jazoest); 143 body.set('lsd', lsd); 144 body.set('__spin_r', spinR); 145 body.set('__spin_b', spinB); 146 body.set('__spin_t', spinT); 147 body.set('fb_api_caller_class', 'RelayModern'); 148 body.set('fb_api_req_friendly_name', ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)}); 149 body.set('variables', JSON.stringify(variables)); 150 body.set('server_timestamps', 'true'); 151 body.set('doc_id', ${JSON.stringify(INSTAGRAM_NOTE_DOC_ID)}); 152 153 const headers = { 154 Accept: '*/*', 155 'Content-Type': 'application/x-www-form-urlencoded', 156 'X-ASBD-ID': asbdId || undefined, 157 'X-CSRFToken': csrfToken, 158 'X-FB-Friendly-Name': ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)}, 159 'X-FB-LSD': lsd, 160 'X-IG-App-ID': appId, 161 'X-Root-Field-Name': ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)}, 162 }; 163 164 const response = await fetch('/graphql/query', { 165 method: 'POST', 166 credentials: 'include', 167 headers, 168 body: body.toString(), 169 }); 170 const text = await response.text(); 171 const normalizedText = text.replace(/^for \\(;;\\);?/, '').trim(); 172 let data = null; 173 try { 174 data = JSON.parse(normalizedText); 175 } catch {} 176 177 const rootField = ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)}; 178 const note = data?.data?.[rootField]?.inbox_tray_item; 179 const noteId = String(note?.inbox_tray_item_id || note?.id || ''); 180 if (response.ok && noteId) { 181 return { 182 ok: true, 183 stage: 'publish', 184 noteId, 185 text: String(note?.note_dict?.text || input.content || ''), 186 }; 187 } 188 189 return { 190 ok: false, 191 stage: 'publish', 192 status: response.status, 193 text: normalizedText || text, 194 }; 195 })() 196 `; 197 } 198 cli({ 199 site: 'instagram', 200 name: 'note', 201 description: 'Publish a text Instagram note', 202 domain: 'www.instagram.com', 203 strategy: Strategy.UI, 204 browser: true, 205 timeoutSeconds: 120, 206 args: [ 207 { name: 'content', positional: true, required: true, help: 'Note text (max 60 characters)' }, 208 ], 209 columns: ['status', 'detail', 'noteId'], 210 validateArgs: validateInstagramNoteArgs, 211 func: async (page, kwargs) => { 212 const browserPage = requirePage(page); 213 const content = normalizeInstagramNoteContent(kwargs); 214 await browserPage.goto(INSTAGRAM_INBOX_URL); 215 await browserPage.wait({ time: 2 }); 216 const result = await browserPage.evaluate(buildPublishInstagramNoteJs(content)); 217 if (!result?.ok) { 218 throw new CommandExecutionError(`Instagram note publish failed at ${String(result?.stage || 'unknown')}: ${String(result?.text || 'unknown error')}`); 219 } 220 return buildNoteSuccessResult(String(result.noteId || '')); 221 }, 222 });