Is PolyPoll - Create Prediction Markets from Tweets & News safe?

High risk

PolyPoll is high risk. When you click PolyPoll's parrot button on Twitter/X, the extension reads the selected tweet's text, author, status URL, tweet ID, and timestamp, then posts these fields to PolyPoll's backend for market-suggestion generation.โ€ฆ

samatcrispyv1.0.3Chrome Web Store
77Risk
Who publishes it

samatcrispy - no other listings under this identity

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
samatcrispy

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

Tweet Data Sent for AI Market Suggestions

When you click PolyPoll's parrot button on Twitter/X, the extension reads the selected tweet's text, author, status URL, tweet ID, and timestamp, then posts these fields to PolyPoll's backend for market-suggestion generation.

Severity
High unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You click the PolyPoll parrot button next to a Twitter/X post.

The button is injected into the tweet action bar by the content script.

The extension did this

The extension reads the tweet fields from the page and sends them to PolyPoll for market-suggestion generation.

The fields include tweet text, author, URL, tweet ID, and timestamp context.

Fields prepared from the selected Twitter/X post
  • Tweet URL
    https://x.com/exampleuser/status/1812865104532283480 (illustrative)

    Identifies the exact post you selected for a market suggestion.

  • Tweet text
    Our product launch is moving to Friday after the final security review. (illustrative)

    Includes the text of the post you selected.

  • Tweet author
    Example User (illustrative)

    Associates the selected post with the account name shown on the page.

  • Tweet ID and time
    1812865104532283480 at 2026-07-14T15:32:00.000Z (illustrative)

    Adds stable context for the selected post and when it appeared.

Captured request
POSThttps://polypoll-backend-frg4.onrender.com/api/v1/twitter-instant-suggest
Headers
Content-Type
application/json
The code that does this

Shipped source path from tweet button to backend POST

Readable version

Readable shipped source: tweet button click

content.js
  parrotButton.addEventListener('click', (e) => {    e.stopPropagation();    e.preventDefault();    log(LOG_LEVELS.INFO, 'UI', '๐Ÿฆœ Parrot button clicked - opening bet modal');    // Extract tweet data and set it as article data    const tweetData = extractTweetData(tweetElement);    polypollContentState.articleData = tweetData;    log(LOG_LEVELS.DEBUG, 'UI', 'Tweet data extracted:', tweetData);    // Track parrot button click    contentPostHog.trackParrotButtonClicked(tweetData);    // Show inline bet modal    showBetModal(tweetData);  });

Readable shipped source: tweet extraction

content.js
function extractTweetData(tweetElement) {  const data = {    url: window.location.href, // Will be updated with specific tweet URL if found    hostname: window.location.hostname,    title: null,    author: null,    date: null,    summary: null,    content: null,    tweetId: null,    tweetUrl: null  };    log(LOG_LEVELS.DEBUG, 'EXTRACT', 'Starting tweet data extraction...');    // Get tweet text - this is the most important part  const tweetText = tweetElement.querySelector('[data-testid="tweetText"]');  if (tweetText) {    data.content = tweetText.textContent?.trim();    data.title = data.content?.substring(0, 100) + (data.content?.length > 100 ? '...' : '');    log(LOG_LEVELS.DEBUG, 'EXTRACT', `Tweet content: "${data.content}"`);  } else {    log(LOG_LEVELS.ERROR, 'EXTRACT', 'No tweet text found!');  }    // Get author - try multiple selectors  let authorElement = tweetElement.querySelector('[data-testid="User-Name"] span') ||                      tweetElement.querySelector('[data-testid="User-Names"] span:first-child') ||                     tweetElement.querySelector('[data-testid="User-Name"]') ||                      tweetElement.querySelector('[data-testid="User-Names"]');    if (authorElement) {    data.author = authorElement.textContent?.trim();    log(LOG_LEVELS.DEBUG, 'EXTRACT', `Tweet author: "${data.author}"`);  }    // Get username for URL construction  let username = null;  const usernameElement = tweetElement.querySelector('[data-testid="User-Names"] a[href^="/"]');  if (usernameElement) {    const href = usernameElement.getAttribute('href');    username = href?.replace('/', ''); // Remove leading slash    log(LOG_LEVELS.DEBUG, 'EXTRACT', `Username: "${username}"`);  }    // Get timestamp  const timeElement = tweetElement.querySelector('time');  if (timeElement) {    data.date = timeElement.getAttribute('datetime') || timeElement.textContent;    log(LOG_LEVELS.DEBUG, 'EXTRACT', `Tweet date: "${data.date}"`);  }    // Extract tweet ID and construct specific tweet URL  // Look for links that contain "/status/"  const statusLink = tweetElement.querySelector('a[href*="/status/"]');  if (statusLink) {    const href = statusLink.getAttribute('href');    const statusMatch = href.match(/\/status\/(\d+)/);    if (statusMatch) {      data.tweetId = statusMatch[1];      // Construct proper tweet URL      if (username) {        data.tweetUrl = `https://x.com/${username}/status/${data.tweetId}`;        data.url = data.tweetUrl; // Use specific tweet URL instead of current page URL      } else {        data.tweetUrl = `https://x.com/i/status/${data.tweetId}`;        data.url = data.tweetUrl;      }      log(LOG_LEVELS.DEBUG, 'EXTRACT', `Tweet ID: ${data.tweetId}`);      log(LOG_LEVELS.DEBUG, 'EXTRACT', `Tweet URL: ${data.tweetUrl}`);    }  }    // If we couldn't find a specific tweet URL, check if current page is a tweet  if (!data.tweetUrl && window.location.pathname.includes('/status/')) {    data.tweetUrl = window.location.href;    data.url = data.tweetUrl;    const urlMatch = window.location.pathname.match(/\/status\/(\d+)/);    if (urlMatch) {      data.tweetId = urlMatch[1];    }    log(LOG_LEVELS.DEBUG, 'EXTRACT', `Using current page as tweet URL: ${data.tweetUrl}`);  }    data.summary = data.content?.substring(0, 200) + (data.content?.length > 200 ? '...' : '');    log(LOG_LEVELS.INFO, 'EXTRACT', `Tweet extraction complete - ID: ${data.tweetId}, Author: ${data.author}, Content: ${data.content?.substring(0, 50)}...`);    return data;}

Readable shipped source: instant suggestion request

content.js
    const suggestionResponse = await fetch(`${CONFIG.API_BASE_URL}/api/v1/twitter-instant-suggest`, {      method: 'POST',      headers: {        'Content-Type': 'application/json'      },      body: JSON.stringify({        tweet_content: tweetData.content,        tweet_author: tweetData.author,        tweet_url: tweetData.url || tweetData.tweetUrl      })    });

Readable shipped source: market creation request

content.js
    const response = await fetch(`${CONFIG.API_BASE_URL}/api/v1/markets-mvp/create-market-fast`, {      method: 'POST',      headers: {        'Content-Type': 'application/json'      },      body: JSON.stringify({        question: `Based on this tweet by ${tweetData.author}: "${tweetData.content}"`,        user_id: 'twitter_extension',        tweet_content: tweetData.content,        tweet_author: tweetData.author,        tweet_url: tweetData.url || tweetData.tweetUrl,        poll_mode: false,        fast_mode: true,        deploy_on_chain: false,        force_duplicate: false      })    });

Readable shipped source: background Twitter branch

background.js
async function handleAIPollGeneration(articleData) {  log(LOG_LEVELS.DEBUG, 'AI', '๐Ÿค– Generating poll with new AI endpoint', {    title: articleData?.title?.substring(0, 50),    contentLength: articleData?.content?.length  });    try {    // Check if this is a Twitter/X URL    const isTwitter = articleData.url && (      articleData.url.includes('twitter.com') ||       articleData.url.includes('x.com')    );        let endpoint, requestBody;        const apiBaseUrl = getApiBaseUrl();    if (isTwitter) {      // Use Twitter instant suggest endpoint (ultra-fast)      log(LOG_LEVELS.INFO, 'AI', '๐Ÿฆ Detected Twitter/X content - using instant suggest');      endpoint = `${apiBaseUrl}/api/v1/twitter-instant-suggest`;      requestBody = {        tweet_content: articleData.content || articleData.summary || '',        tweet_author: articleData.author || 'Unknown',        tweet_url: articleData.url || null,        user_id: 'twitter_extension'      };    } else {      // Use regular article poll endpoint      endpoint = `${apiBaseUrl}/api/ai/generate-poll`;      requestBody = {        title: articleData.title || 'Untitled Article',        content: articleData.content || articleData.summary || 'No content available',        url: articleData.url || 'https://example.com'      };    }        log(LOG_LEVELS.DEBUG, 'AI', '๐Ÿ“ก Making request to AI endpoint', {      endpoint: endpoint,      bodyKeys: Object.keys(requestBody),      isTwitter: isTwitter    });        const response = await fetch(endpoint, {      method: 'POST',      headers: {        'Accept': 'application/json',        'Content-Type': 'application/json'      },      body: JSON.stringify(requestBody)    });        if (!response.ok) {      const errorText = await response.text();      log(LOG_LEVELS.ERROR, 'AI', `โŒ API request failed: ${response.status}`, errorText);      throw new Error(`AI API error (${response.status}): ${errorText}`);    }        const aiResponse = await response.json();    log(LOG_LEVELS.INFO, 'AI', 'โœ… AI response received', {      hasQuestion: !!aiResponse.question,      hasOptions: !!aiResponse.options,      optionCount: aiResponse.options?.length || 0,      isTwitterPoll: isTwitter    });        // Handle Twitter binary poll response format    if (isTwitter && aiResponse.options) {      // Convert binary poll format to standard format if needed      return {        question: aiResponse.question,        options: aiResponse.options.map(opt => ({          text: opt.text,          reasoning: opt.reasoning,          expected_percentage: opt.expected_percentage        })),        metadata: {          poll_type: 'binary',          confidence_level: aiResponse.confidence_level,          resolution_timeframe: aiResponse.resolution_timeframe,          ...aiResponse.metadata        },        analysis: aiResponse.reasoning || aiResponse.analysis      };    }        // Return the full AI response with all the rich data for regular polls    return {      question: aiResponse.question,      serial_optimism: aiResponse.serial_optimism,      contrarian: aiResponse.contrarian,      pessimistic: aiResponse.pessimistic,      hottake: aiResponse.hottake,      options: aiResponse.options,      metadata: aiResponse.metadata,      analysis: aiResponse.analysis    };      } catch (error) {    log(LOG_LEVELS.ERROR, 'AI', 'โŒ AI poll generation failed', error);    throw error;  }}
External host receiving tweet data
    • polypoll-backend-frg4.onrender.com

    PolyPoll backend endpoint that receives tweet fields for instant market suggestions and market creation.

Article Data Sent for AI Poll Generation

When you click PolyPoll's floating parrot button on an article page, the extension collects the page URL, title, and article text, then posts them to PolyPoll's AI poll-generation endpoint.

Severity
High unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You click the PolyPoll parrot button while viewing an article page.

The click opens the poll creation flow inside the page.

The extension did this

The extension reads article fields from the page and sends them to PolyPoll for AI poll generation.

The fields include the current URL, page title, and article text selected by the extractor.

Fields prepared for the article poll-generation request
  • Article URL
    https://www.reuters.com/world/us/federal-reserve-rate-decision-2026-07-14/ (illustrative)

    Shows the exact article page you used to create the poll.

  • Page title
    Fed officials weigh July rate decision as markets watch inflation data (illustrative)

    Identifies the headline or title of the article you selected.

  • Article text
    Federal Reserve officials signaled that inflation and labor-market data would guide the next rate decision. (illustrative)

    Includes body text from the article, which can reveal what topic you were reading.

Captured request
POSThttps://polypoll-backend-frg4.onrender.com/api/ai/generate-poll
Headers
Accept
application/json
Content-Type
application/json
The code that does this

Shipped source path from page click to backend POST

Readable version

Readable shipped source: article button click

content.js
  mascot.querySelector('.polly-image').addEventListener('click', () => {    log(LOG_LEVELS.INFO, 'UI', '๐Ÿฆœ Polly FAB clicked - extracting article content');    // Extract fresh article data from the DOM    const articleData = extractArticleData();    polypollContentState.articleData = articleData;    log(LOG_LEVELS.DEBUG, 'UI', 'Article data extracted:', articleData);    // Use the same modal as Twitter parrot button    showBetModal(articleData);  });

Readable shipped source: article extraction

content.js
function extractArticleData() {  const { extractor, type } = getExtractor(window.location.hostname);  polypollContentState.extractorUsed = type;    const data = {    url: window.location.href,    hostname: window.location.hostname,    title: null,    author: null,    date: null,    summary: null,    content: null,    extractorType: type  };    log(LOG_LEVELS.DEBUG, 'EXTRACT', `Starting extraction with ${type} extractor`);    // Extract title  const titleSelectors = extractor.title.split(', ');  for (const selector of titleSelectors) {    try {      const titleEl = document.querySelector(selector);      if (titleEl) {        data.title = titleEl.textContent?.trim();        log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Title found with selector: ${selector}`);        break;      }    } catch (e) {      log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Title selector failed: ${selector}`);    }  }    // Extract author  const authorSelectors = extractor.author.split(', ');  for (const selector of authorSelectors) {    try {      const authorEl = document.querySelector(selector);      if (authorEl) {        data.author = authorEl.textContent?.trim();        log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Author found with selector: ${selector}`);        break;      }    } catch (e) {      log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Author selector failed: ${selector}`);    }  }    // Extract date  const dateSelectors = extractor.date.split(', ');  for (const selector of dateSelectors) {    try {      const dateEl = document.querySelector(selector);      if (dateEl) {        data.date = dateEl.getAttribute('datetime') || dateEl.textContent?.trim();        log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Date found with selector: ${selector}`);        break;      }    } catch (e) {      log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Date selector failed: ${selector}`);    }  }    // Extract summary  const summarySelectors = extractor.summary.split(', ');  for (const selector of summarySelectors) {    try {      const summaryEl = document.querySelector(selector);      if (summaryEl) {        data.summary = summaryEl.textContent?.trim();        log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Summary found with selector: ${selector}`);        break;      }    } catch (e) {      log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Summary selector failed: ${selector}`);    }  }    // Extract content - aggregate from multiple sources for better context  const contentPieces = new Set(); // Use Set to avoid duplicates  const MAX_CONTENT_LENGTH = 5000; // Reasonable threshold for AI context  let totalLength = 0;    // Add WSJ and paywall-specific selectors  const wsjSelectors = [    '.wsj-snippet-body',    '[data-module-zone="article_snippet"]',    '.snippet__body',    '.article-wrap',    'div[class*="snippet"]',    'div[class*="article"]',    'div[class*="story"]',    '.paywall',    '.truncated-content'  ];    // First, try to get content from article selectors  const articleSelectors = extractor.article.split(', ');  const allSelectors = [...articleSelectors, ...wsjSelectors];    for (const selector of allSelectors) {    try {      const articleEls = document.querySelectorAll(selector);      articleEls.forEach(articleEl => {        if (totalLength >= MAX_CONTENT_LENGTH) return;                // Get all text elements (broader extraction)        const textElements = articleEl.querySelectorAll('p, h1, h2, h3, h4, blockquote, li, span');        Array.from(textElements).forEach(el => {          const text = el.textContent?.trim();          if (text && text.length > 20 && !contentPieces.has(text)) {            // Filter out common non-content text            const lowerText = text.toLowerCase();            if (!lowerText.includes('copyright') &&                 !lowerText.includes('all rights reserved') &&                !lowerText.includes('continue reading') &&                !lowerText.includes('subscribe') &&                !lowerText.includes('sign up')) {              contentPieces.add(text);              totalLength += text.length;            }          }        });                // If still not enough, get the full text content        if (totalLength < 300) {          const fullText = articleEl.textContent?.trim();          if (fullText && fullText.length > 100 && !contentPieces.has(fullText)) {            // Clean up the text            const cleanText = fullText.replace(/\s+/g, ' ').substring(0, 1000);            contentPieces.add(cleanText);            totalLength += cleanText.length;          }        }      });    } catch (e) {      log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Article selector failed: ${selector}`);    }  }    // If we don't have enough content, broaden the search  if (totalLength < 500) {    const additionalSelectors = [      'main p',      '[role="main"] p',      '.content p',      '.article-body p',      '.story-body p',      'article p',      '.post-content p',      '.entry-content p'    ];        additionalSelectors.forEach(selector => {      if (totalLength >= MAX_CONTENT_LENGTH) return;            try {        const paragraphs = document.querySelectorAll(selector);        paragraphs.forEach(p => {          const text = p.textContent?.trim();          if (text && text.length > 30 && !contentPieces.has(text) && totalLength < MAX_CONTENT_LENGTH) {            contentPieces.add(text);            totalLength += text.length;          }        });      } catch (e) {        log(LOG_LEVELS.VERBOSE, 'EXTRACT', `Additional selector failed: ${selector}`);      }    });  }    // Convert Set to array and join  data.content = Array.from(contentPieces).join('\n\n');    // If still insufficient, use fallback extraction  if (data.content.length < 200) {    log(LOG_LEVELS.INFO, 'EXTRACT', 'Insufficient content, using fallback extraction');    data.content = getPageTextContent();  }    // Ensure we don't exceed reasonable limits  if (data.content.length > MAX_CONTENT_LENGTH) {    data.content = data.content.substring(0, MAX_CONTENT_LENGTH) + '...';  }    log(LOG_LEVELS.INFO, 'EXTRACT', `Content extraction complete`, {    length: data.content.length,    pieces: contentPieces.size  });    // Fallback to meta tags  if (!data.title) {    const metaTitle = document.querySelector('meta[property="og:title"], meta[name="twitter:title"]');    if (metaTitle) {      data.title = metaTitle.getAttribute('content');      log(LOG_LEVELS.DEBUG, 'EXTRACT', 'Title extracted from meta tags');    }  }    if (!data.summary) {    const metaDesc = document.querySelector('meta[property="og:description"], meta[name="description"]');    if (metaDesc) {      data.summary = metaDesc.getAttribute('content');      log(LOG_LEVELS.DEBUG, 'EXTRACT', 'Summary extracted from meta tags');    }  }    log(LOG_LEVELS.INFO, 'EXTRACT', 'Article extraction complete', {    hasTitle: !!data.title,    hasAuthor: !!data.author,    hasDate: !!data.date,    hasSummary: !!data.summary,    hasContent: !!data.content,    contentLength: data.content?.length || 0  });    return data;}

Readable shipped source: runtime message fields

content.js
  function triggerAIGeneration() {    log(LOG_LEVELS.INFO, 'AI', '๐Ÿค– Auto-triggering AI poll generation');        chrome.runtime.sendMessage({      action: 'generatePollWithAI',      data: {        title: polypollContentState.articleData?.title || '',        content: polypollContentState.articleData?.content || polypollContentState.articleData?.summary || '',        url: polypollContentState.articleData?.url || window.location.href      }    }, (response) => {      if (response && response.success) {        log(LOG_LEVELS.INFO, 'AI', 'โœ… AI generation successful', response.data);                // Hide context menu loading if it exists        hideContextMenuLoading();                // Hide loading skeletons and populate fields        const questionSkeleton = modal.querySelector('#question-skeleton');        const aiLoadingMessage = modal.querySelector('#ai-loading-message');        const questionDisplay = modal.querySelector('#poll-question-display');        const questionText = modal.querySelector('#poll-question-text');        const questionInput = modal.querySelector('#poll-question');        const aiContainer = modal.querySelector('.ai-options-container');        const aiOptionsDiv = modal.querySelector('#ai-generated-options');                // Hide loading elements        questionSkeleton.style.display = 'none';        questionSkeleton.classList.remove('show');        aiLoadingMessage.style.display = 'none';                // Populate question in the styled display        const generatedQuestion = response.data.question || 'What will happen?';        questionText.innerHTML = generatedQuestion;        questionText.classList.add('loaded');        questionInput.value = generatedQuestion; // Store in hidden input for form submission                // Show AI options container and populate        aiContainer.style.display = 'block';                // Clear existing AI options        aiOptionsDiv.innerHTML = '';                // Add AI-generated options with proper emojis (randomized order)        const perspectiveMap = {          'serial_optimism': '๐Ÿš€',          'contrarian': '๐Ÿ”„',          'pessimistic': '๐Ÿ’€',          'hottake': '๐Ÿ”ฅ'        };                // Collect all valid options and randomize order        const options = [];        Object.entries(perspectiveMap).forEach(([key, emoji]) => {          if (response.data[key]) {            options.push({              key: key,              emoji: emoji,              text: response.data[key]            });          }        });                // Randomize the array        options.sort(() => Math.random() - 0.5);                // Render options in randomized order        const stakeControls = modal.querySelector('#stake-controls');                options.forEach((option, index) => {          const optionDiv = document.createElement('div');          optionDiv.className = 'perspective-option ai-generated clean-style';          optionDiv.dataset.optionId = `option-${index}`;          optionDiv.innerHTML = `            <div class="option-indicator">${String.fromCharCode(65 + index)}</div>            <div class="option-content">              <div class="option-emoji">${option.emoji}</div>              <div class="option-text-wrapper">                <div class="poll-option ai-option"                      data-perspective="${option.key}">                  ${option.text}                </div>              </div>            </div>            <div class="option-hover-effect"></div>          `;                    // Add click handler for option selection          optionDiv.addEventListener('click', () => {            // Remove selected class from all options            modal.querySelectorAll('.perspective-option').forEach(opt => {              opt.classList.remove('selected');            });            // Add selected class to this option            optionDiv.classList.add('selected');            // Show stake controls with animation            if (stakeControls) {              stakeControls.style.display = 'block';              // Scroll to stake controls smoothly              setTimeout(() => {                stakeControls.scrollIntoView({ behavior: 'smooth', block: 'nearest' });              }, 100);            }          });                    aiOptionsDiv.appendChild(optionDiv);        });              } else {        log(LOG_LEVELS.ERROR, 'AI', 'โŒ AI generation failed', response?.error);                // Hide context menu loading if it exists        hideContextMenuLoading();                // Hide loading and show error state        const questionSkeleton = modal.querySelector('#question-skeleton');        const aiLoadingMessage = modal.querySelector('#ai-loading-message');        const questionText = modal.querySelector('#poll-question-text');        const questionInput = modal.querySelector('#poll-question');                questionSkeleton.style.display = 'none';        aiLoadingMessage.innerHTML = `          <div class="ai-error-content">            <div class="error-icon">โŒ</div>            <h3>AI Generation Failed</h3>            <p>Please refresh and try again</p>          </div>        `;                questionText.textContent = 'Failed to generate question - Please try again';        questionText.classList.add('error');        questionInput.value = '';                showNotification('Failed to generate poll with AI. Please create manually.', 'error');      }    });  }

Readable shipped source: backend POST branch

background.js
async function handleAIPollGeneration(articleData) {  log(LOG_LEVELS.DEBUG, 'AI', '๐Ÿค– Generating poll with new AI endpoint', {    title: articleData?.title?.substring(0, 50),    contentLength: articleData?.content?.length  });    try {    // Check if this is a Twitter/X URL    const isTwitter = articleData.url && (      articleData.url.includes('twitter.com') ||       articleData.url.includes('x.com')    );        let endpoint, requestBody;        const apiBaseUrl = getApiBaseUrl();    if (isTwitter) {      // Use Twitter instant suggest endpoint (ultra-fast)      log(LOG_LEVELS.INFO, 'AI', '๐Ÿฆ Detected Twitter/X content - using instant suggest');      endpoint = `${apiBaseUrl}/api/v1/twitter-instant-suggest`;      requestBody = {        tweet_content: articleData.content || articleData.summary || '',        tweet_author: articleData.author || 'Unknown',        tweet_url: articleData.url || null,        user_id: 'twitter_extension'      };    } else {      // Use regular article poll endpoint      endpoint = `${apiBaseUrl}/api/ai/generate-poll`;      requestBody = {        title: articleData.title || 'Untitled Article',        content: articleData.content || articleData.summary || 'No content available',        url: articleData.url || 'https://example.com'      };    }        log(LOG_LEVELS.DEBUG, 'AI', '๐Ÿ“ก Making request to AI endpoint', {      endpoint: endpoint,      bodyKeys: Object.keys(requestBody),      isTwitter: isTwitter    });        const response = await fetch(endpoint, {      method: 'POST',      headers: {        'Accept': 'application/json',        'Content-Type': 'application/json'      },      body: JSON.stringify(requestBody)    });        if (!response.ok) {      const errorText = await response.text();      log(LOG_LEVELS.ERROR, 'AI', `โŒ API request failed: ${response.status}`, errorText);      throw new Error(`AI API error (${response.status}): ${errorText}`);    }        const aiResponse = await response.json();    log(LOG_LEVELS.INFO, 'AI', 'โœ… AI response received', {      hasQuestion: !!aiResponse.question,      hasOptions: !!aiResponse.options,      optionCount: aiResponse.options?.length || 0,      isTwitterPoll: isTwitter    });        // Handle Twitter binary poll response format    if (isTwitter && aiResponse.options) {      // Convert binary poll format to standard format if needed      return {        question: aiResponse.question,        options: aiResponse.options.map(opt => ({          text: opt.text,          reasoning: opt.reasoning,          expected_percentage: opt.expected_percentage        })),        metadata: {          poll_type: 'binary',          confidence_level: aiResponse.confidence_level,          resolution_timeframe: aiResponse.resolution_timeframe,          ...aiResponse.metadata        },        analysis: aiResponse.reasoning || aiResponse.analysis      };    }        // Return the full AI response with all the rich data for regular polls    return {      question: aiResponse.question,      serial_optimism: aiResponse.serial_optimism,      contrarian: aiResponse.contrarian,      pessimistic: aiResponse.pessimistic,      hottake: aiResponse.hottake,      options: aiResponse.options,      metadata: aiResponse.metadata,      analysis: aiResponse.analysis    };      } catch (error) {    log(LOG_LEVELS.ERROR, 'AI', 'โŒ AI poll generation failed', error);    throw error;  }}
External host receiving article data
    • polypoll-backend-frg4.onrender.com

    PolyPoll backend endpoint that receives article fields for AI poll generation.

Updated 30 September 2026dieciobphmiegdifnbhlgabobfabdbej