accept.js
1 import { CommandExecutionError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 cli({ 4 site: 'twitter', 5 name: 'accept', 6 description: 'Auto-accept DM requests containing specific keywords', 7 domain: 'x.com', 8 strategy: Strategy.UI, 9 browser: true, 10 timeoutSeconds: 600, // 10 min — batch operation iterating many conversations 11 args: [ 12 { name: 'query', type: 'string', required: true, positional: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' }, 13 { name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' }, 14 ], 15 columns: ['index', 'status', 'user', 'message'], 16 func: async (page, kwargs) => { 17 if (!page) 18 throw new CommandExecutionError('Browser session required for twitter accept'); 19 const keywords = kwargs.query.split(',').map((k) => k.trim()).filter(Boolean); 20 const maxAccepts = kwargs.max ?? 20; 21 const results = []; 22 let acceptCount = 0; 23 // Track already-visited conversations to avoid infinite loops 24 const visited = new Set(); 25 for (let round = 0; round < maxAccepts + 50; round++) { 26 if (acceptCount >= maxAccepts) 27 break; 28 // Step 1: Navigate to DM requests page 29 await page.goto('https://x.com/messages/requests'); 30 await page.wait(4); 31 // Step 2: Get conversations with scroll-to-load 32 const convInfo = await page.evaluate(`(async () => { 33 try { 34 // Wait for initial items 35 let attempts = 0; 36 while (attempts < 10) { 37 const convs = document.querySelectorAll('[data-testid="conversation"]'); 38 if (convs.length > 0) break; 39 await new Promise(r => setTimeout(r, 1000)); 40 attempts++; 41 } 42 43 // Scroll to load more 44 const seenCount = new Set(); 45 let noNewCount = 0; 46 for (let scroll = 0; scroll < 20; scroll++) { 47 const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]')); 48 const prevSize = seenCount.size; 49 convs.forEach((_, i) => seenCount.add(i)); 50 if (convs.length >= ${maxAccepts + 10}) break; 51 52 // Scroll last item into view 53 if (convs.length > 0) { 54 convs[convs.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' }); 55 } 56 await new Promise(r => setTimeout(r, 1500)); 57 58 if (seenCount.size <= prevSize) { 59 noNewCount++; 60 if (noNewCount >= 3) break; 61 } else { 62 noNewCount = 0; 63 } 64 } 65 66 const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]')); 67 if (convs.length === 0) return { ok: false, count: 0, items: [] }; 68 69 const items = convs.map((conv, idx) => { 70 const text = conv.innerText || ''; 71 const link = conv.querySelector('a[href]'); 72 const href = link ? link.href : ''; 73 const lines = text.split('\\n').filter(l => l.trim()); 74 const user = lines[0] || 'Unknown'; 75 return { idx, text, href, user }; 76 }); 77 return { ok: true, count: convs.length, items }; 78 } catch(e) { 79 return { ok: false, error: String(e), count: 0, items: [] }; 80 } 81 })()`); 82 if (!convInfo?.ok || convInfo.count === 0) { 83 if (results.length === 0) { 84 results.push({ index: 1, status: 'info', user: 'System', message: 'No message requests found' }); 85 } 86 break; 87 } 88 let foundInThisRound = false; 89 // Step 3: Find first unvisited conversation with keyword match in preview 90 for (const item of convInfo.items) { 91 if (acceptCount >= maxAccepts) 92 break; 93 const convKey = item.href || `conv-${item.idx}`; 94 if (visited.has(convKey)) 95 continue; 96 visited.add(convKey); 97 // Check if preview text contains any keyword 98 const previewMatch = keywords.some((k) => item.text.includes(k)); 99 if (!previewMatch) 100 continue; 101 // Step 4: Click this conversation to open it 102 const clickResult = await page.evaluate(`(async () => { 103 try { 104 const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]')); 105 const conv = convs[${item.idx}]; 106 if (!conv) return { ok: false, error: 'Conversation element not found' }; 107 conv.click(); 108 await new Promise(r => setTimeout(r, 2000)); 109 return { ok: true }; 110 } catch(e) { 111 return { ok: false, error: String(e) }; 112 } 113 })()`); 114 if (!clickResult?.ok) 115 continue; 116 // Wait for conversation to load 117 await page.wait(2); 118 // Step 5: Read full chat content and find Accept button 119 const res = await page.evaluate(`(async () => { 120 try { 121 const keywords = ${JSON.stringify(keywords)}; 122 123 // Get username from conversation header 124 const heading = document.querySelector('[data-testid="conversation-header"]') || 125 document.querySelector('[data-testid="DM-conversation-header"]'); 126 let username = 'Unknown'; 127 if (heading) { 128 username = heading.innerText.trim().split('\\n')[0]; 129 } 130 131 // Read full chat area text 132 const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') || 133 document.querySelector('[data-testid="DMConversationBody"]') || 134 document.querySelector('main [data-testid="cellInnerDiv"]')?.closest('section') || 135 document.querySelector('main'); 136 const text = chatArea ? chatArea.innerText : ''; 137 138 // Verify keyword match in full chat content 139 const matchedKw = keywords.filter(k => text.includes(k)); 140 if (matchedKw.length === 0) { 141 return { status: 'skipped', user: username, message: 'No keyword match in full content' }; 142 } 143 144 // Find the Accept button 145 const allBtns = Array.from(document.querySelectorAll('[role="button"]')); 146 const acceptBtn = allBtns.find(btn => { 147 const t = btn.innerText.trim().toLowerCase(); 148 return t === 'accept' || t === '接受'; 149 }); 150 151 if (!acceptBtn) { 152 return { status: 'no_button', user: username, message: 'Keyword matched but no Accept button (already accepted?)' }; 153 } 154 155 // Click Accept 156 acceptBtn.click(); 157 await new Promise(r => setTimeout(r, 2000)); 158 159 // Check for confirmation dialog 160 const btnsAfter = Array.from(document.querySelectorAll('[role="button"]')); 161 const confirmBtn = btnsAfter.find(btn => { 162 const t = btn.innerText.trim().toLowerCase(); 163 return (t === 'accept' || t === '接受') && btn !== acceptBtn; 164 }); 165 if (confirmBtn) { 166 confirmBtn.click(); 167 await new Promise(r => setTimeout(r, 1000)); 168 } 169 170 return { status: 'accepted', user: username, message: 'Accepted! Matched: ' + matchedKw.join(', ') }; 171 } catch(e) { 172 return { status: 'error', user: 'system', message: String(e) }; 173 } 174 })()`); 175 if (res?.status === 'accepted') { 176 acceptCount++; 177 foundInThisRound = true; 178 results.push({ 179 index: acceptCount, 180 status: 'accepted', 181 user: res.user || 'Unknown', 182 message: res.message || 'Accepted', 183 }); 184 // After accept, Twitter redirects to /messages — loop back to /messages/requests 185 await page.wait(2); 186 break; // break inner loop, outer loop will re-navigate to requests 187 } 188 else if (res?.status === 'no_button') { 189 // Already accepted, skip 190 continue; 191 } 192 } 193 // If no match found in this round, we've exhausted all visible requests 194 if (!foundInThisRound) { 195 break; 196 } 197 } 198 if (results.length === 0) { 199 results.push({ index: 0, status: 'info', user: 'System', message: `No requests matched keywords "${keywords.join(', ')}"` }); 200 } 201 return results; 202 } 203 });