Analise o script Python atual em ~/Scripts/tools/instagram-story-viewers.py para entender:
1. Como ele extrai os viewers via AppleScript/JavaScript
2. Como faz scroll na lista
3. Como salva os dados
4. O que precisa ser adaptado para navegar entre múltiplos stories automaticamente

Documente a lógica principal e os pontos de integração.


Vou analisar o script Python do Instagram Story Viewers em detalhes.


Bash

find /Users/neog/Scripts -name "*instagram*" -o -name "*story*" 2>/dev/null | head -20

Search for Instagram and story related files


/Users/neog/Scripts/tools/instagram-story-viewers.py
/Users/neog/Scripts/system/scrape-predictive-history.applescript


No files found


Ótimo! O arquivo existe. Vou lê-lo junto com arquivos relacionados.


     1→#!/usr/bin/env python3
     2→"""
     3→Instagram Story Viewers Extractor
     4→Extrai perfis de pessoas que visualizaram seus stories via Safari DevTools.
     5→
     6→Uso:
     7→1. Abra o Safari e vá para instagram.com
     8→2. Faça login na sua conta
     9→3. Abra seu story e clique para ver a lista de viewers
    10→4. Execute este script
    11→
    12→Requisitos:
    13→- Safari com "Allow JavaScript from Apple Events" habilitado
    14→  (Safari > Develop > Allow JavaScript from Apple Events)
    15→"""
    16→
    17→import subprocess
    18→import json
    19→import csv
    20→import os
    21→from datetime import datetime
    22→from pathlib import Path
    23→import time
    24→import re
    25→
    26→# Diretório para salvar os dados
    27→OUTPUT_DIR = Path.home() / "Documents" / "instagram-data"
    28→OUTPUT_DIR.mkdir(exist_ok=True)
    29→
    30→def run_applescript(script: str) -> str:
    31→    """Executa AppleScript e retorna o resultado."""
    32→    result = subprocess.run(
    33→        ["osascript", "-e", script],
    34→        capture_output=True,
    35→        text=True
    36→    )
    37→    if result.returncode != 0:
    38→        raise Exception(f"AppleScript error: {result.stderr}")
    39→    return result.stdout.strip()
    40→
    41→def check_safari_devtools():
    42→    """Verifica se o Safari está configurado corretamente."""
    43→    check_script = '''
    44→    tell application "Safari"
    45→        if (count of windows) = 0 then
    46→            return "NO_WINDOW"
    47→        end if
    48→        return "OK"
    49→    end tell
    50→    '''
    51→    try:
    52→        result = run_applescript(check_script)
    53→        if result == "NO_WINDOW":
    54→            print("❌ Nenhuma janela do Safari aberta.")
    55→            print("   Abra o Safari e navegue até seu story no Instagram.")
    56→            return False
    57→        return True
    58→    except Exception as e:
    59→        print(f"❌ Erro ao verificar Safari: {e}")
    60→        print("\n⚠  Verifique se o Safari Developer está habilitado:")
    61→        print("   Safari > Settings > Advanced > Show features for web developers")
    62→        print("   Safari > Develop > Allow JavaScript from Apple Events")
    63→        return False
    64→
    65→def get_current_url() -> str:
    66→    """Obtém a URL atual do Safari."""
    67→    script = '''
    68→    tell application "Safari"
    69→        return URL of current tab of window 1
    70→    end tell
    71→    '''
    72→    return run_applescript(script)
    73→
    74→def extract_story_viewers() -> list:
    75→    """Extrai os viewers do story via JavaScript no Safari."""
    76→
    77→    # JavaScript otimizado para o formato do Instagram
    78→    js_code = '''
    79→    (function() {
    80→        let viewers = [];
    81→
    82→        // Busca todas as imagens de perfil com alt text que termina em "'s profile picture"
    83→        let profilePics = document.querySelectorAll('img[alt$="\\'s profile picture"]');
    84→
    85→        profilePics.forEach(img => {
    86→            let alt = img.getAttribute('alt') || '';
    87→            let match = alt.match(/^(.+)'s profile picture$/);
    88→
    89→            if (match) {
    90→                let username = match[1];
    91→
    92→                // Busca o elemento pai que contém o nome completo
    93→                let container = img.closest('div[role="button"]') ||
    94→                              img.closest('a[href]') ||
    95→                              img.closest('div');
    96→
    97→                let fullName = '';
    98→                let profileUrl = 'https://instagram.com/' + username;
    99→
   100→                if (container) {
   101→                    // Busca todos os spans no container
   102→                    let spans = container.querySelectorAll('span');
   103→                    spans.forEach(span => {
   104→                        let text = span.textContent.trim();
   105→                        // O nome completo geralmente não é igual ao username
   106→                        if (text && text !== username && text.length > 0 && text.length < 100) {
   107→                            if (!fullName || text.length > fullName.length) {
   108→                                fullName = text;
   109→                            }
   110→                        }
   111→                    });
   112→
   113→                    // Busca link do perfil
   114→                    let link = container.closest('a[href^="/"]') ||
   115→                              container.querySelector('a[href^="/"]');
   116→                    if (link) {
   117→                        let href = link.getAttribute('href');
   118→                        if (href && href.startsWith('/')) {
   119→                            profileUrl = 'https://instagram.com' + href;
   120→                        }
   121→                    }
   122→                }
   123→
   124→                viewers.push({
   125→                    username: username,
   126→                    name: fullName,
   127→                    profile_url: profileUrl,
   128→                    profile_pic: img.src
   129→                });
   130→            }
   131→        });
   132→
   133→        // Método alternativo: buscar por estrutura de lista de viewers
   134→        if (viewers.length === 0) {
   135→            let dialog = document.querySelector('div[role="dialog"]');
   136→            if (dialog) {
   137→                let links = dialog.querySelectorAll('a[href^="/"]');
   138→                links.forEach(a => {
   139→                    let href = a.getAttribute('href');
   140→                    if (href && href.match(/^\\/[^/]+\\/?$/)) {
   141→                        let username = href.replace(/\\//g, '');
   142→                        if (username && !['explore', 'direct', 'accounts', 'p', 'reel'].includes(username)) {
   143→                            let img = a.querySelector('img');
   144→                            let spans = a.querySelectorAll('span');
   145→                            let name = '';
   146→                            spans.forEach(s => {
   147→                                if (s.textContent !== username && s.textContent.length > 0) {
   148→                                    name = s.textContent;
   149→                                }
   150→                            });
   151→
   152→                            viewers.push({
   153→                                username: username,
   154→                                name: name,
   155→                                profile_url: 'https://instagram.com/' + username,
   156→                                profile_pic: img ? img.src : ''
   157→                            });
   158→                        }
   159→                    }
   160→                });
   161→            }
   162→        }
   163→
   164→        // Remove duplicatas mantendo a ordem
   165→        let unique = [];
   166→        let seen = new Set();
   167→        viewers.forEach(v => {
   168→            if (!seen.has(v.username)) {
   169→                seen.add(v.username);
   170→                unique.push(v);
   171→            }
   172→        });
   173→
   174→        return JSON.stringify(unique);
   175→    })();
   176→    '''
   177→
   178→    js_escaped = js_code.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ' ')
   179→
   180→    script = f'''
   181→    tell application "Safari"
   182→        set jsResult to do JavaScript "{js_escaped}" in current tab of window 1
   183→        return jsResult
   184→    end tell
   185→    '''
   186→
   187→    result = run_applescript(script)
   188→
   189→    try:
   190→        viewers = json.loads(result)
   191→        return viewers
   192→    except json.JSONDecodeError:
   193→        print(f"⚠  Erro ao parsear: {result[:200]}")
   194→        return []
   195→
   196→def get_page_text() -> str:
   197→    """Obtém o texto visível da página."""
   198→    js_code = '''
   199→    (function() {
   200→        let dialog = document.querySelector('div[role="dialog"]');
   201→        if (dialog) {
   202→            return dialog.innerText;
   203→        }
   204→        return document.body.innerText;
   205→    })();
   206→    '''
   207→
   208→    js_escaped = js_code.replace('"', '\\"').replace('\n', ' ')
   209→
   210→    script = f'''
   211→    tell application "Safari"
   212→        set jsResult to do JavaScript "{js_escaped}" in current tab of window 1
   213→        return jsResult
   214→    end tell
   215→    '''
   216→
   217→    return run_applescript(script)
   218→
   219→def parse_viewers_from_text(text: str) -> list:
   220→    """Parseia viewers a partir do texto da página (fallback)."""
   221→    viewers = []
   222→    lines = text.split('\n')
   223→
   224→    i = 0
   225→    while i < len(lines):
   226→        line = lines[i].strip()
   227→
   228→        # Procura padrão: "username's profile picture"
   229→        if line.endswith("'s profile picture"):
   230→            username = line.replace("'s profile picture", "").strip()
   231→
   232→            # Próxima linha deve ser o username novamente
   233→            # E a seguinte o nome completo
   234→            name = ""
   235→            if i + 2 < len(lines):
   236→                next_line = lines[i + 1].strip()
   237→                name_line = lines[i + 2].strip()
   238→
   239→                if next_line == username:
   240→                    name = name_line
   241→                    i += 2
   242→
   243→            viewers.append({
   244→                "username": username,
   245→                "name": name,
   246→                "profile_url": f"https://instagram.com/{username}",
   247→                "profile_pic": ""
   248→            })
   249→
   250→        i += 1
   251→
   252→    return viewers
   253→
   254→def scroll_viewers_list(direction: str = "down"):
   255→    """Rola a lista de viewers."""
   256→    scroll_amount = 500 if direction == "down" else -500
   257→
   258→    js_code = f'''
   259→    (function() {{
   260→        let dialog = document.querySelector('div[role="dialog"]');
   261→        if (dialog) {{
   262→            let scrollable = dialog.querySelector('div[style*="overflow"]') ||
   263→                           dialog.querySelector('ul')?.parentElement ||
   264→                           dialog;
   265→
   266→            // Tenta encontrar o elemento scrollável
   267→            let elements = dialog.querySelectorAll('div');
   268→            for (let el of elements) {{
   269→                if (el.scrollHeight > el.clientHeight) {{
   270→                    el.scrollTop += {scroll_amount};
   271→                    return "scrolled";
   272→                }}
   273→            }}
   274→        }}
   275→
   276→        // Fallback: scroll na janela
   277→        window.scrollBy(0, {scroll_amount});
   278→        return "window_scroll";
   279→    }})();
   280→    '''
   281→
   282→    js_escaped = js_code.replace('"', '\\"').replace('\n', ' ')
   283→
   284→    script = f'''
   285→    tell application "Safari"
   286→        do JavaScript "{js_escaped}" in current tab of window 1
   287→    end tell
   288→    '''
   289→
   290→    run_applescript(script)
   291→
   292→def save_viewers(viewers: list, story_id: str = "story"):
   293→    """Salva os viewers em JSON e CSV."""
   294→    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
   295→
   296→    # JSON
   297→    json_file = OUTPUT_DIR / f"viewers_{story_id}_{timestamp}.json"
   298→    with open(json_file, 'w', encoding='utf-8') as f:
   299→        json.dump({
   300→            "extracted_at": datetime.now().isoformat(),
   301→            "story_id": story_id,
   302→            "count": len(viewers),
   303→            "viewers": viewers
   304→        }, f, indent=2, ensure_ascii=False)
   305→
   306→    # CSV
   307→    csv_file = OUTPUT_DIR / f"viewers_{story_id}_{timestamp}.csv"
   308→    with open(csv_file, 'w', newline='', encoding='utf-8') as f:
   309→        writer = csv.DictWriter(f, fieldnames=['username', 'name', 'profile_url', 'profile_pic'])
   310→        writer.writeheader()
   311→        writer.writerows(viewers)
   312→
   313→    return json_file, csv_file
   314→
   315→def extract_all_with_scroll(max_scrolls: int = 20) -> list:
   316→    """Extrai todos os viewers rolando a lista."""
   317→    all_viewers = {}
   318→
   319→    print("   Extraindo viewers com scroll automático...")
   320→
   321→    for i in range(max_scrolls):
   322→        # Extrai viewers visíveis
   323→        viewers = extract_story_viewers()
   324→
   325→        # Se não encontrou via JS, tenta via texto
   326→        if not viewers:
   327→            text = get_page_text()
   328→            viewers = parse_viewers_from_text(text)
   329→
   330→        new_count = 0
   331→        for v in viewers:
   332→            if v['username'] not in all_viewers:
   333→                all_viewers[v['username']] = v
   334→                new_count += 1
   335→
   336→        print(f"   Scroll {i+1}: +{new_count} novos (total: {len(all_viewers)})")
   337→
   338→        if new_count == 0 and i > 2:
   339→            # Nenhum novo viewer após alguns scrolls
   340→            break
   341→
   342→        # Rola para baixo
   343→        scroll_viewers_list("down")
   344→        time.sleep(0.8)  # Aguarda carregamento
   345→
   346→    return list(all_viewers.values())
   347→
   348→def monitor_mode(interval: int = 30):
   349→    """Modo de monitoramento contínuo para novos viewers."""
   350→    print(f"\n🔄 Modo monitor: verificando a cada {interval} segundos")
   351→    print("   Pressione Ctrl+C para parar\n")
   352→
   353→    all_viewers = {}
   354→    session_start = datetime.now()
   355→
   356→    try:
   357→        while True:
   358→            viewers = extract_story_viewers()
   359→            if not viewers:
   360→                text = get_page_text()
   361→                viewers = parse_viewers_from_text(text)
   362→
   363→            new_this_round = []
   364→            for v in viewers:
   365→                if v['username'] not in all_viewers:
   366→                    all_viewers[v['username']] = v
   367→                    v['first_seen'] = datetime.now().isoformat()
   368→                    new_this_round.append(v)
   369→                    print(f"   ✨ Novo: @{v['username']} - {v.get('name', '')}")
   370→
   371→            if new_this_round:
   372→                elapsed = (datetime.now() - session_start).seconds // 60
   373→                print(f"   📊 Total: {len(all_viewers)} viewers ({elapsed} min)")
   374→
   375→            time.sleep(interval)
   376→
   377→    except KeyboardInterrupt:
   378→        print(f"\n\n📊 Sessão encerrada")
   379→        print(f"   Duração: {(datetime.now() - session_start).seconds // 60} minutos")
   380→        print(f"   Total capturado: {len(all_viewers)} viewers")
   381→
   382→        if all_viewers:
   383→            json_file, csv_file = save_viewers(list(all_viewers.values()), "monitor")
   384→            print(f"\n💾 Salvo em: {json_file}")
   385→
   386→def print_viewers(viewers: list, limit: int = 30):
   387→    """Imprime lista formatada de viewers."""
   388→    print(f"\n{'='*60}")
   389→    print(f"{'#':>3} | {'@username':<25} | Nome")
   390→    print(f"{'='*60}")
   391→
   392→    for i, v in enumerate(viewers[:limit], 1):
   393→        name = v.get('name', '')[:25]
   394→        username = v['username'][:25]
   395→        print(f"{i:>3} | @{username:<24} | {name}")
   396→
   397→    if len(viewers) > limit:
   398→        print(f"{'='*60}")
   399→        print(f"... e mais {len(viewers) - limit} viewers")
   400→
   401→def main():
   402→    print("\n" + "="*60)
   403→    print("📸 Instagram Story Viewers Extractor")
   404→    print("="*60)
   405→
   406→    # Verifica Safari
   407→    if not check_safari_devtools():
   408→        return
   409→
   410→    # Verifica URL
   411→    url = get_current_url()
   412→    print(f"\n📍 URL: {url}")
   413→
   414→    if "instagram.com" not in url:
   415→        print("\n⚠  Você não está no Instagram!")
   416→        print("   Navegue até instagram.com e abra seu story.")
   417→        return
   418→
   419→    print("\n📋 Certifique-se que:")
   420→    print("   1. Você está logado no Instagram")
   421→    print("   2. Seu story está aberto")
   422→    print("   3. A lista de viewers está visível")
   423→    print("      (clique no ícone de visualizações)")
   424→
   425→    input("\n   Pressione ENTER quando pronto...")
   426→
   427→    print("\n🔍 Extraindo viewers...")
   428→
   429→    # Primeira tentativa
   430→    viewers = extract_story_viewers()
   431→
   432→    # Fallback: parse do texto
   433→    if not viewers:
   434→        print("   Método 1 falhou, tentando via texto...")
   435→        text = get_page_text()
   436→        viewers = parse_viewers_from_text(text)
   437→
   438→    if viewers:
   439→        print(f"\n✅ Encontrados {len(viewers)} viewers!")
   440→        print_viewers(viewers)
   441→
   442→        # Menu de opções
   443→        print("\n" + "-"*40)
   444→        print("Opções:")
   445→        print("  [1] Salvar dados")
   446→        print("  [2] Extrair mais (scroll automático)")
   447→        print("  [3] Modo monitor (novos viewers)")
   448→        print("  [4] Sair")
   449→
   450→        choice = input("\nEscolha: ").strip()
   451→
   452→        if choice == "1":
   453→            json_file, csv_file = save_viewers(viewers)
   454→            print(f"\n💾 Salvo!")
   455→            print(f"   JSON: {json_file}")
   456→            print(f"   CSV:  {csv_file}")
   457→
   458→        elif choice == "2":
   459→            all_viewers = extract_all_with_scroll()
   460→            print(f"\n✅ Total extraído: {len(all_viewers)} viewers")
   461→            print_viewers(all_viewers)
   462→
   463→            save = input("\nSalvar? (s/n): ")
   464→            if save.lower() == 's':
   465→                json_file, csv_file = save_viewers(all_viewers)
   466→                print(f"💾 Salvo em: {json_file}")
   467→
   468→        elif choice == "3":
   469→            interval = input("Intervalo em segundos (30): ").strip()
   470→            interval = int(interval) if interval else 30
   471→            monitor_mode(interval)
   472→
   473→    else:
   474→        print("\n❌ Nenhum viewer encontrado.")
   475→        print("\n   Possíveis soluções:")
   476→        print("   1. Certifique-se que a lista de viewers está aberta")
   477→        print("   2. Role a lista um pouco manualmente")
   478→        print("   3. Aguarde a lista carregar completamente")
   479→
   480→        retry = input("\n   Tentar novamente com scroll? (s/n): ")
   481→        if retry.lower() == 's':
   482→            all_viewers = extract_all_with_scroll()
   483→            if all_viewers:
   484→                print(f"\n✅ Encontrados {len(all_viewers)} viewers!")
   485→                print_viewers(all_viewers)
   486→                json_file, csv_file = save_viewers(all_viewers)
   487→                print(f"\n💾 Salvo em: {json_file}")
   488→
   489→if __name__ == "__main__":
   490→    main()
   491→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

#!/usr/bin/env python3
"""
Instagram Story Viewers Extractor
Extrai perfis de pessoas que visualizaram seus stories via Safari DevTools.

Uso:
1. Abra o Safari e vá para instagram.com
2. Faça login na sua conta
3. Abra seu story e clique para ver a lista de viewers
4. Execute este script

Requisitos:
- Safari com "Allow JavaScript from Apple Events" habilitado
  (Safari > Develop > Allow JavaScript from Apple Events)
"""

import subprocess
import json
import csv
import os
from datetime import datetime
from pathlib import Path
import time
import re

# Diretório para salvar os dados
OUTPUT_DIR = Path.home() / "Documents" / "instagram-data"
OUTPUT_DIR.mkdir(exist_ok=True)

def run_applescript(script: str) -> str:
    """Executa AppleScript e retorna o resultado."""
    result = subprocess.run(
        ["osascript", "-e", script],
        capture_output=True,
        text=True
    )
    if result.returncode != 0:
        raise Exception(f"AppleScript error: {result.stderr}")
    return result.stdout.strip()

def check_safari_devtools():
    """Verifica se o Safari está configurado corretamente."""
    check_script = '''
    tell application "Safari"
        if (count of windows) = 0 then
            return "NO_WINDOW"
        end if
        return "OK"
    end tell
    '''
    try:
        result = run_applescript(check_script)
        if result == "NO_WINDOW":
            print("❌ Nenhuma janela do Safari aberta.")
            print("   Abra o Safari e navegue até seu story no Instagram.")
            return False
        return True
    except Exception as e:
        print(f"❌ Erro ao verificar Safari: {e}")
        print("\n⚠  Verifique se o Safari Developer está habilitado:")
        print("   Safari > Settings > Advanced > Show features for web developers")
        print("   Safari > Develop > Allow JavaScript from Apple Events")
        return False

def get_current_url() -> str:
    """Obtém a URL atual do Safari."""
    script = '''
    tell application "Safari"
        return URL of current tab of window 1
    end tell
    '''
    return run_applescript(script)

def extract_story_viewers() -> list:
    """Extrai os viewers do story via JavaScript no Safari."""

    # JavaScript otimizado para o formato do Instagram
    js_code = '''
    (function() {
        let viewers = [];

        // Busca todas as imagens de perfil com alt text que termina em "'s profile picture"
        let profilePics = document.querySelectorAll('img[alt$="\\'s profile picture"]');

        profilePics.forEach(img => {
            let alt = img.getAttribute('alt') || '';
            let match = alt.match(/^(.+)'s profile picture$/);

            if (match) {
                let username = match[1];

                // Busca o elemento pai que contém o nome completo
                let container = img.closest('div[role="button"]') ||
                              img.closest('a[href]') ||
                              img.closest('div');

                let fullName = '';
                let profileUrl = 'https://instagram.com/' + username;

                if (container) {
                    // Busca todos os spans no container
                    let spans = container.querySelectorAll('span');
                    spans.forEach(span => {
                        let text = span.textContent.trim();
                        // O nome completo geralmente não é igual ao username
                        if (text && text !== username && text.length > 0 && text.length < 100) {
                            if (!fullName || text.length > fullName.length) {
                                fullName = text;
                            }
                        }
                    });

                    // Busca link do perfil
                    let link = container.closest('a[href^="/"]') ||
                              container.querySelector('a[href^="/"]');
                    if (link) {
                        let href = link.getAttribute('href');
                        if (href && href.startsWith('/')) {
                            profileUrl = 'https://instagram.com' + href;
                        }
                    }
                }

                viewers.push({
                    username: username,
                    name: fullName,
                    profile_url: profileUrl,
                    profile_pic: img.src
                });
            }
        });

        // Método alternativo: buscar por estrutura de lista de viewers
        if (viewers.length === 0) {
            let dialog = document.querySelector('div[role="dialog"]');
            if (dialog) {
                let links = dialog.querySelectorAll('a[href^="/"]');
                links.forEach(a => {
                    let href = a.getAttribute('href');
                    if (href && href.match(/^\\/[^/]+\\/?$/)) {
                        let username = href.replace(/\\//g, '');
                        if (username && !['explore', 'direct', 'accounts', 'p', 'reel'].includes(username)) {
                            let img = a.querySelector('img');
                            let spans = a.querySelectorAll('span');
                            let name = '';
                            spans.forEach(s => {
                                if (s.textContent !== username && s.textContent.length > 0) {
                                    name = s.textContent;
                                }
                            });

                            viewers.push({
                                username: username,
                                name: name,
                                profile_url: 'https://instagram.com/' + username,
                                profile_pic: img ? img.src : ''
                            });
                        }
                    }
                });
            }
        }

        // Remove duplicatas mantendo a ordem
        let unique = [];
        let seen = new Set();
        viewers.forEach(v => {
            if (!seen.has(v.username)) {
                seen.add(v.username);
                unique.push(v);
            }
        });

        return JSON.stringify(unique);
    })();
    '''

    js_escaped = js_code.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ' ')

    script = f'''
    tell application "Safari"
        set jsResult to do JavaScript "{js_escaped}" in current tab of window 1
        return jsResult
    end tell
    '''

    result = run_applescript(script)

    try:
        viewers = json.loads(result)
        return viewers
    except json.JSONDecodeError:
        print(f"⚠  Erro ao parsear: {result[:200]}")
        return []

def get_page_text() -> str:
    """Obtém o texto visível da página."""
    js_code = '''
    (function() {
        let dialog = document.querySelector('div[role="dialog"]');
        if (dialog) {
            return dialog.innerText;
        }
        return document.body.innerText;
    })();
    '''

    js_escaped = js_code.replace('"', '\\"').replace('\n', ' ')

    script = f'''
    tell application "Safari"
        set jsResult to do JavaScript "{js_escaped}" in current tab of window 1
        return jsResult
    end tell
    '''

    return run_applescript(script)

def parse_viewers_from_text(text: str) -> list:
    """Parseia viewers a partir do texto da página (fallback)."""
    viewers = []
    lines = text.split('\n')

    i = 0
    while i < len(lines):
        line = lines[i].strip()

        # Procura padrão: "username's profile picture"
        if line.endswith("'s profile picture"):
            username = line.replace("'s profile picture", "").strip()

            # Próxima linha deve ser o username novamente
            # E a seguinte o nome completo
            name = ""
            if i + 2 < len(lines):
                next_line = lines[i + 1].strip()
                name_line = lines[i + 2].strip()

                if next_line == username:
                    name = name_line
                    i += 2

            viewers.append({
                "username": username,
                "name": name,
                "profile_url": f"https://instagram.com/{username}",
                "profile_pic": ""
            })

        i += 1

    return viewers

def scroll_viewers_list(direction: str = "down"):
    """Rola a lista de viewers."""
    scroll_amount = 500 if direction == "down" else -500

    js_code = f'''
    (function() {{
        let dialog = document.querySelector('div[role="dialog"]');
        if (dialog) {{
            let scrollable = dialog.querySelector('div[style*="overflow"]') ||
                           dialog.querySelector('ul')?.parentElement ||
                           dialog;

            // Tenta encontrar o elemento scrollável
            let elements = dialog.querySelectorAll('div');
            for (let el of elements) {{
                if (el.scrollHeight > el.clientHeight) {{
                    el.scrollTop += {scroll_amount};
                    return "scrolled";
                }}
            }}
        }}

        // Fallback: scroll na janela
        window.scrollBy(0, {scroll_amount});
        return "window_scroll";
    }})();
    '''

    js_escaped = js_code.replace('"', '\\"').replace('\n', ' ')

    script = f'''
    tell application "Safari"
        do JavaScript "{js_escaped}" in current tab of window 1
    end tell
    '''

    run_applescript(script)

def save_viewers(viewers: list, story_id: str = "story"):
    """Salva os viewers em JSON e CSV."""
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

    # JSON
    json_file = OUTPUT_DIR / f"viewers_{story_id}_{timestamp}.json"
    with open(json_file, 'w', encoding='utf-8') as f:
        json.dump({
            "extracted_at": datetime.now().isoformat(),
            "story_id": story_id,
            "count": len(viewers),
            "viewers": viewers
        }, f, indent=2, ensure_ascii=False)

    # CSV
    csv_file = OUTPUT_DIR / f"viewers_{story_id}_{timestamp}.csv"
    with open(csv_file, 'w', newline='', encoding='utf-8') as f:
        writer = csv.DictWriter(f, fieldnames=['username', 'name', 'profile_url', 'profile_pic'])
        writer.writeheader()
        writer.writerows(viewers)

    return json_file, csv_file

def extract_all_with_scroll(max_scrolls: int = 20) -> list:
    """Extrai todos os viewers rolando a lista."""
    all_viewers = {}

    print("   Extraindo viewers com scroll automático...")

    for i in range(max_scrolls):
        # Extrai viewers visíveis
        viewers = extract_story_viewers()

        # Se não encontrou via JS, tenta via texto
        if not viewers:
            text = get_page_text()
            viewers = parse_viewers_from_text(text)

        new_count = 0
        for v in viewers:
            if v['username'] not in all_viewers:
                all_viewers[v['username']] = v
                new_count += 1

        print(f"   Scroll {i+1}: +{new_count} novos (total: {len(all_viewers)})")

        if new_count == 0 and i > 2:
            # Nenhum novo viewer após alguns scrolls
            break

        # Rola para baixo
        scroll_viewers_list("down")
        time.sleep(0.8)  # Aguarda carregamento

    return list(all_viewers.values())

def monitor_mode(interval: int = 30):
    """Modo de monitoramento contínuo para novos viewers."""
    print(f"\n🔄 Modo monitor: verificando a cada {interval} segundos")
    print("   Pressione Ctrl+C para parar\n")

    all_viewers = {}
    session_start = datetime.now()

    try:
        while True:
            viewers = extract_story_viewers()
            if not viewers:
                text = get_page_text()
                viewers = parse_viewers_from_text(text)

            new_this_round = []
            for v in viewers:
                if v['username'] not in all_viewers:
                    all_viewers[v['username']] = v
                    v['first_seen'] = datetime.now().isoformat()
                    new_this_round.append(v)
                    print(f"   ✨ Novo: @{v['username']} - {v.get('name', '')}")

            if new_this_round:
                elapsed = (datetime.now() - session_start).seconds // 60
                print(f"   📊 Total: {len(all_viewers)} viewers ({elapsed} min)")

            time.sleep(interval)

    except KeyboardInterrupt:
        print(f"\n\n📊 Sessão encerrada")
        print(f"   Duração: {(datetime.now() - session_start).seconds // 60} minutos")
        print(f"   Total capturado: {len(all_viewers)} viewers")

        if all_viewers:
            json_file, csv_file = save_viewers(list(all_viewers.values()), "monitor")
            print(f"\n💾 Salvo em: {json_file}")

def print_viewers(viewers: list, limit: int = 30):
    """Imprime lista formatada de viewers."""
    print(f"\n{'='*60}")
    print(f"{'#':>3} | {'@username':<25} | Nome")
    print(f"{'='*60}")

    for i, v in enumerate(viewers[:limit], 1):
        name = v.get('name', '')[:25]
        username = v['username'][:25]
        print(f"{i:>3} | @{username:<24} | {name}")

    if len(viewers) > limit:
        print(f"{'='*60}")
        print(f"... e mais {len(viewers) - limit} viewers")

def main():
    print("\n" + "="*60)
    print("📸 Instagram Story Viewers Extractor")
    print("="*60)

    # Verifica Safari
    if not check_safari_devtools():
        return

    # Verifica URL
    url = get_current_url()
    print(f"\n📍 URL: {url}")

    if "instagram.com" not in url:
        print("\n⚠  Você não está no Instagram!")
        print("   Navegue até instagram.com e abra seu story.")
        return

    print("\n📋 Certifique-se que:")
    print("   1. Você está logado no Instagram")
    print("   2. Seu story está aberto")
    print("   3. A lista de viewers está visível")
    print("      (clique no ícone de visualizações)")

    input("\n   Pressione ENTER quando pronto...")

    print("\n🔍 Extraindo viewers...")

    # Primeira tentativa
    viewers = extract_story_viewers()

    # Fallback: parse do texto
    if not viewers:
        print("   Método 1 falhou, tentando via texto...")
        text = get_page_text()
        viewers = parse_viewers_from_text(text)

    if viewers:
        print(f"\n✅ Encontrados {len(viewers)} viewers!")
        print_viewers(viewers)

        # Menu de opções
        print("\n" + "-"*40)
        print("Opções:")
        print("  [1] Salvar dados")
        print("  [2] Extrair mais (scroll automático)")
        print("  [3] Modo monitor (novos viewers)")
        print("  [4] Sair")

        choice = input("\nEscolha: ").strip()

        if choice == "1":
            json_file, csv_file = save_viewers(viewers)
            print(f"\n💾 Salvo!")
            print(f"   JSON: {json_file}")
            print(f"   CSV:  {csv_file}")

        elif choice == "2":
            all_viewers = extract_all_with_scroll()
            print(f"\n✅ Total extraído: {len(all_viewers)} viewers")
            print_viewers(all_viewers)

            save = input("\nSalvar? (s/n): ")
            if save.lower() == 's':
                json_file, csv_file = save_viewers(all_viewers)
                print(f"💾 Salvo em: {json_file}")

        elif choice == "3":
            interval = input("Intervalo em segundos (30): ").strip()
            interval = int(interval) if interval else 30
            monitor_mode(interval)

    else:
        print("\n❌ Nenhum viewer encontrado.")
        print("\n   Possíveis soluções:")
        print("   1. Certifique-se que a lista de viewers está aberta")
        print("   2. Role a lista um pouco manualmente")
        print("   3. Aguarde a lista carregar completamente")

        retry = input("\n   Tentar novamente com scroll? (s/n): ")
        if retry.lower() == 's':
            all_viewers = extract_all_with_scroll()
            if all_viewers:
                print(f"\n✅ Encontrados {len(all_viewers)} viewers!")
                print_viewers(all_viewers)
                json_file, csv_file = save_viewers(all_viewers)
                print(f"\n💾 Salvo em: {json_file}")

if __name__ == "__main__":
    main()


Excelente! Encontrei o script. Agora vou procurar por arquivos relacionados e outros exemplos de automação.


ls -la /Users/neog/Scripts/tools/ 2>/dev/null | head -30

List tools directory


find /Users/neog/Scripts -name "*.applescript" -o -name "*automation*" 2>/dev/null

Find AppleScript and automation files


total 56
drwx------   4 neog  staff    128 Jan  2 17:43 .
drwxr-xr-x@ 11 neog  staff    352 Dec 31 21:48 ..
-rwx--x--x   1 neog  staff  16243 Jan  2 17:43 instagram-story-viewers.py
-rwx--x--x   1 neog  staff   9227 Dec 31 21:57 youtube-channel-extractor.py


Found 3 files
Scripts/tools/instagram-story-viewers.py
Scripts/system/neog-cleanse-execute-archive.sh
Scripts/system/neog-cleanse-redundancy-analyzer.sh


Vou ler o script relacionado do YouTube para comparar padrões:


     1→#!/usr/bin/env python3
     2→"""
     3→YouTube Channel Video Extractor via Safari Developer Tools
     4→Extrai todos os vídeos de um canal do YouTube usando Safari + AppleScript
     5→Exporta em TXT, CSV e JSON
     6→"""
     7→
     8→import subprocess
     9→import json
    10→import csv
    11→import time
    12→import sys
    13→import re
    14→from pathlib import Path
    15→
    16→def run_applescript(script: str) -> str:
    17→    """Executa AppleScript e retorna o resultado."""
    18→    result = subprocess.run(
    19→        ['osascript', '-e', script],
    20→        capture_output=True,
    21→        text=True
    22→    )
    23→    if result.returncode != 0:
    24→        raise Exception(f"AppleScript error: {result.stderr}")
    25→    return result.stdout.strip()
    26→
    27→def open_safari_url(url: str):
    28→    """Abre URL no Safari."""
    29→    script = f'''
    30→    tell application "Safari"
    31→        activate
    32→        if (count of windows) = 0 then
    33→            make new document
    34→        end if
    35→        set URL of document 1 to "{url}"
    36→    end tell
    37→    '''
    38→    run_applescript(script)
    39→
    40→def execute_js_in_safari(js_code: str) -> str:
    41→    """Executa JavaScript no Safari e retorna resultado."""
    42→    # Escape aspas para AppleScript
    43→    js_escaped = js_code.replace('\\', '\\\\').replace('"', '\\"')
    44→    script = f'''
    45→    tell application "Safari"
    46→        do JavaScript "{js_escaped}" in document 1
    47→    end tell
    48→    '''
    49→    return run_applescript(script)
    50→
    51→def wait_for_page_load(timeout: int = 30):
    52→    """Aguarda a página carregar."""
    53→    print("⏳ Aguardando página carregar...")
    54→    time.sleep(3)
    55→
    56→    for _ in range(timeout):
    57→        try:
    58→            state = execute_js_in_safari("document.readyState")
    59→            if state == "complete":
    60→                return True
    61→        except:
    62→            pass
    63→        time.sleep(1)
    64→    return False
    65→
    66→def scroll_to_load_all_videos():
    67→    """Faz scroll para carregar todos os vídeos (lazy loading)."""
    68→    print("📜 Fazendo scroll para carregar todos os vídeos...")
    69→
    70→    js_scroll = '''
    71→    (function() {
    72→        return new Promise((resolve) => {
    73→            let lastHeight = 0;
    74→            let sameCount = 0;
    75→
    76→            const interval = setInterval(() => {
    77→                window.scrollTo(0, document.documentElement.scrollHeight);
    78→
    79→                const currentHeight = document.documentElement.scrollHeight;
    80→                if (currentHeight === lastHeight) {
    81→                    sameCount++;
    82→                    if (sameCount >= 5) {
    83→                        clearInterval(interval);
    84→                        resolve('done');
    85→                    }
    86→                } else {
    87→                    sameCount = 0;
    88→                    lastHeight = currentHeight;
    89→                }
    90→            }, 1000);
    91→        });
    92→    })();
    93→    'scrolling'
    94→    '''
    95→
    96→    execute_js_in_safari(js_scroll)
    97→
    98→    # Aguarda scroll terminar (verifica quando para de crescer)
    99→    last_count = 0
   100→    stable_count = 0
   101→
   102→    while stable_count < 5:
   103→        time.sleep(2)
   104→        try:
   105→            count = int(execute_js_in_safari(
   106→                "document.querySelectorAll('ytd-rich-item-renderer, ytd-grid-video-renderer').length"
   107→            ))
   108→            print(f"   Vídeos carregados: {count}")
   109→
   110→            if count == last_count:
   111→                stable_count += 1
   112→            else:
   113→                stable_count = 0
   114→                last_count = count
   115→                # Continua scrollando
   116→                execute_js_in_safari("window.scrollTo(0, document.documentElement.scrollHeight)")
   117→        except:
   118→            stable_count += 1
   119→
   120→    print(f"✅ Total de vídeos encontrados: {last_count}")
   121→
   122→def extract_videos() -> list:
   123→    """Extrai todos os vídeos da página."""
   124→    print("🔍 Extraindo informações dos vídeos...")
   125→
   126→    js_extract = '''
   127→    (function() {
   128→        const videos = [];
   129→
   130→        // Tenta diferentes seletores (YouTube muda frequentemente)
   131→        const selectors = [
   132→            'ytd-rich-item-renderer #video-title-link',
   133→            'ytd-grid-video-renderer #video-title',
   134→            'ytd-rich-grid-media #video-title-link',
   135→            '#contents ytd-rich-item-renderer a#video-title-link',
   136→            '#contents a#video-title'
   137→        ];
   138→
   139→        let elements = [];
   140→        for (const selector of selectors) {
   141→            elements = document.querySelectorAll(selector);
   142→            if (elements.length > 0) break;
   143→        }
   144→
   145→        elements.forEach((el, index) => {
   146→            const title = el.textContent?.trim() || el.getAttribute('title') || '';
   147→            let href = el.getAttribute('href') || '';
   148→
   149→            if (href && !href.startsWith('http')) {
   150→                href = 'https://www.youtube.com' + href;
   151→            }
   152→
   153→            if (title && href && href.includes('/watch')) {
   154→                videos.push({
   155→                    index: index + 1,
   156→                    title: title,
   157→                    url: href
   158→                });
   159→            }
   160→        });
   161→
   162→        return JSON.stringify(videos);
   163→    })();
   164→    '''
   165→
   166→    result = execute_js_in_safari(js_extract)
   167→
   168→    try:
   169→        videos = json.loads(result)
   170→        return videos
   171→    except json.JSONDecodeError:
   172→        print(f"⚠  Erro ao parsear JSON: {result[:200]}")
   173→        return []
   174→
   175→def get_channel_name() -> str:
   176→    """Obtém o nome do canal."""
   177→    try:
   178→        js = "document.querySelector('#channel-name, #text.ytd-channel-name, yt-formatted-string.ytd-channel-name')?.textContent?.trim() || 'unknown'"
   179→        return execute_js_in_safari(js)
   180→    except:
   181→        return "unknown"
   182→
   183→def main():
   184→    # URL do canal
   185→    if len(sys.argv) > 1:
   186→        channel_url = sys.argv[1]
   187→    else:
   188→        channel_url = "https://www.youtube.com/@gomesnzt/videos"
   189→
   190→    # Garante que está na aba de vídeos
   191→    if not channel_url.endswith('/videos'):
   192→        if channel_url.endswith('/'):
   193→            channel_url += 'videos'
   194→        else:
   195→            channel_url += '/videos'
   196→
   197→    print(f"🎬 YouTube Channel Video Extractor")
   198→    print(f"{'='*50}")
   199→    print(f"📺 Canal: {channel_url}\n")
   200→
   201→    # Abre Safari
   202→    print("🌐 Abrindo Safari...")
   203→    open_safari_url(channel_url)
   204→
   205→    # Aguarda carregar
   206→    if not wait_for_page_load():
   207→        print("❌ Timeout ao carregar página")
   208→        return
   209→
   210→    time.sleep(3)  # Aguarda render inicial
   211→
   212→    # Obtém nome do canal
   213→    channel_name = get_channel_name()
   214→    print(f"📺 Canal: {channel_name}\n")
   215→
   216→    # Scroll para carregar todos os vídeos
   217→    scroll_to_load_all_videos()
   218→
   219→    # Extrai vídeos
   220→    videos = extract_videos()
   221→
   222→    if not videos:
   223→        print("❌ Nenhum vídeo encontrado. Verifique se a página carregou corretamente.")
   224→        return
   225→
   226→    # Inverte a ordem (mais antigo primeiro)
   227→    videos.reverse()
   228→
   229→    # Exibe resultados
   230→    print(f"\n{'='*50}")
   231→    print(f"📋 LISTA DE VÍDEOS (mais antigo → mais recente)")
   232→    print(f"{'='*50}\n")
   233→
   234→    for i, video in enumerate(videos, 1):
   235→        print(f"{i:3}. {video['title']}")
   236→        print(f"     {video['url']}\n")
   237→
   238→    # Extrai handle do canal da URL
   239→    handle_match = re.search(r'@([\w-]+)', channel_url)
   240→    handle = handle_match.group(1) if handle_match else channel_name.replace(' ', '_')
   241→
   242→    base_path = Path("/Users/neog/Downloads") / handle
   243→
   244→    # === TXT ===
   245→    txt_file = f"{base_path}_videos.txt"
   246→    with open(txt_file, 'w', encoding='utf-8') as f:
   247→        f.write(f"Canal: {channel_name}\n")
   248→        f.write(f"Handle: @{handle}\n")
   249→        f.write(f"URL: {channel_url}\n")
   250→        f.write(f"Total: {len(videos)} vídeos\n")
   251→        f.write(f"Ordem: mais antigo → mais recente\n")
   252→        f.write(f"{'='*50}\n\n")
   253→
   254→        for i, video in enumerate(videos, 1):
   255→            f.write(f"{i}. {video['title']}\n")
   256→            f.write(f"   {video['url']}\n\n")
   257→
   258→    # === CSV ===
   259→    csv_file = f"{base_path}_videos.csv"
   260→    with open(csv_file, 'w', encoding='utf-8', newline='') as f:
   261→        writer = csv.writer(f)
   262→        writer.writerow(['#', 'Título', 'URL', 'Video ID'])
   263→        for i, video in enumerate(videos, 1):
   264→            # Extrai video ID da URL
   265→            video_id_match = re.search(r'v=([a-zA-Z0-9_-]+)', video['url'])
   266→            video_id = video_id_match.group(1) if video_id_match else ''
   267→            writer.writerow([i, video['title'], video['url'], video_id])
   268→
   269→    # === JSON ===
   270→    json_file = f"{base_path}_videos.json"
   271→    json_data = {
   272→        'channel': {
   273→            'name': channel_name,
   274→            'handle': f'@{handle}',
   275→            'url': channel_url
   276→        },
   277→        'metadata': {
   278→            'total_videos': len(videos),
   279→            'order': 'oldest_to_newest',
   280→            'extracted_at': time.strftime('%Y-%m-%d %H:%M:%S')
   281→        },
   282→        'videos': []
   283→    }
   284→
   285→    for i, video in enumerate(videos, 1):
   286→        video_id_match = re.search(r'v=([a-zA-Z0-9_-]+)', video['url'])
   287→        video_id = video_id_match.group(1) if video_id_match else ''
   288→        json_data['videos'].append({
   289→            'index': i,
   290→            'title': video['title'],
   291→            'url': video['url'],
   292→            'video_id': video_id
   293→        })
   294→
   295→    with open(json_file, 'w', encoding='utf-8') as f:
   296→        json.dump(json_data, f, ensure_ascii=False, indent=2)
   297→
   298→    print(f"\n💾 Arquivos salvos:")
   299→    print(f"   📄 TXT:  {txt_file}")
   300→    print(f"   📊 CSV:  {csv_file}")
   301→    print(f"   📋 JSON: {json_file}")
   302→    print(f"\n📊 Total: {len(videos)} vídeos")
   303→
   304→if __name__ == "__main__":
   305→    main()
   306→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

#!/usr/bin/env python3
"""
YouTube Channel Video Extractor via Safari Developer Tools
Extrai todos os vídeos de um canal do YouTube usando Safari + AppleScript
Exporta em TXT, CSV e JSON
"""

import subprocess
import json
import csv
import time
import sys
import re
from pathlib import Path

def run_applescript(script: str) -> str:
    """Executa AppleScript e retorna o resultado."""
    result = subprocess.run(
        ['osascript', '-e', script],
        capture_output=True,
        text=True
    )
    if result.returncode != 0:
        raise Exception(f"AppleScript error: {result.stderr}")
    return result.stdout.strip()

def open_safari_url(url: str):
    """Abre URL no Safari."""
    script = f'''
    tell application "Safari"
        activate
        if (count of windows) = 0 then
            make new document
        end if
        set URL of document 1 to "{url}"
    end tell
    '''
    run_applescript(script)

def execute_js_in_safari(js_code: str) -> str:
    """Executa JavaScript no Safari e retorna resultado."""
    # Escape aspas para AppleScript
    js_escaped = js_code.replace('\\', '\\\\').replace('"', '\\"')
    script = f'''
    tell application "Safari"
        do JavaScript "{js_escaped}" in document 1
    end tell
    '''
    return run_applescript(script)

def wait_for_page_load(timeout: int = 30):
    """Aguarda a página carregar."""
    print("⏳ Aguardando página carregar...")
    time.sleep(3)

    for _ in range(timeout):
        try:
            state = execute_js_in_safari("document.readyState")
            if state == "complete":
                return True
        except:
            pass
        time.sleep(1)
    return False

def scroll_to_load_all_videos():
    """Faz scroll para carregar todos os vídeos (lazy loading)."""
    print("📜 Fazendo scroll para carregar todos os vídeos...")

    js_scroll = '''
    (function() {
        return new Promise((resolve) => {
            let lastHeight = 0;
            let sameCount = 0;

            const interval = setInterval(() => {
                window.scrollTo(0, document.documentElement.scrollHeight);

                const currentHeight = document.documentElement.scrollHeight;
                if (currentHeight === lastHeight) {
                    sameCount++;
                    if (sameCount >= 5) {
                        clearInterval(interval);
                        resolve('done');
                    }
                } else {
                    sameCount = 0;
                    lastHeight = currentHeight;
                }
            }, 1000);
        });
    })();
    'scrolling'
    '''

    execute_js_in_safari(js_scroll)

    # Aguarda scroll terminar (verifica quando para de crescer)
    last_count = 0
    stable_count = 0

    while stable_count < 5:
        time.sleep(2)
        try:
            count = int(execute_js_in_safari(
                "document.querySelectorAll('ytd-rich-item-renderer, ytd-grid-video-renderer').length"
            ))
            print(f"   Vídeos carregados: {count}")

            if count == last_count:
                stable_count += 1
            else:
                stable_count = 0
                last_count = count
                # Continua scrollando
                execute_js_in_safari("window.scrollTo(0, document.documentElement.scrollHeight)")
        except:
            stable_count += 1

    print(f"✅ Total de vídeos encontrados: {last_count}")

def extract_videos() -> list:
    """Extrai todos os vídeos da página."""
    print("🔍 Extraindo informações dos vídeos...")

    js_extract = '''
    (function() {
        const videos = [];

        // Tenta diferentes seletores (YouTube muda frequentemente)
        const selectors = [
            'ytd-rich-item-renderer #video-title-link',
            'ytd-grid-video-renderer #video-title',
            'ytd-rich-grid-media #video-title-link',
            '#contents ytd-rich-item-renderer a#video-title-link',
            '#contents a#video-title'
        ];

        let elements = [];
        for (const selector of selectors) {
            elements = document.querySelectorAll(selector);
            if (elements.length > 0) break;
        }

        elements.forEach((el, index) => {
            const title = el.textContent?.trim() || el.getAttribute('title') || '';
            let href = el.getAttribute('href') || '';

            if (href && !href.startsWith('http')) {
                href = 'https://www.youtube.com' + href;
            }

            if (title && href && href.includes('/watch')) {
                videos.push({
                    index: index + 1,
                    title: title,
                    url: href
                });
            }
        });

        return JSON.stringify(videos);
    })();
    '''

    result = execute_js_in_safari(js_extract)

    try:
        videos = json.loads(result)
        return videos
    except json.JSONDecodeError:
        print(f"⚠  Erro ao parsear JSON: {result[:200]}")
        return []

def get_channel_name() -> str:
    """Obtém o nome do canal."""
    try:
        js = "document.querySelector('#channel-name, #text.ytd-channel-name, yt-formatted-string.ytd-channel-name')?.textContent?.trim() || 'unknown'"
        return execute_js_in_safari(js)
    except:
        return "unknown"

def main():
    # URL do canal
    if len(sys.argv) > 1:
        channel_url = sys.argv[1]
    else:
        channel_url = "https://www.youtube.com/@gomesnzt/videos"

    # Garante que está na aba de vídeos
    if not channel_url.endswith('/videos'):
        if channel_url.endswith('/'):
            channel_url += 'videos'
        else:
            channel_url += '/videos'

    print(f"🎬 YouTube Channel Video Extractor")
    print(f"{'='*50}")
    print(f"📺 Canal: {channel_url}\n")

    # Abre Safari
    print("🌐 Abrindo Safari...")
    open_safari_url(channel_url)

    # Aguarda carregar
    if not wait_for_page_load():
        print("❌ Timeout ao carregar página")
        return

    time.sleep(3)  # Aguarda render inicial

    # Obtém nome do canal
    channel_name = get_channel_name()
    print(f"📺 Canal: {channel_name}\n")

    # Scroll para carregar todos os vídeos
    scroll_to_load_all_videos()

    # Extrai vídeos
    videos = extract_videos()

    if not videos:
        print("❌ Nenhum vídeo encontrado. Verifique se a página carregou corretamente.")
        return

    # Inverte a ordem (mais antigo primeiro)
    videos.reverse()

    # Exibe resultados
    print(f"\n{'='*50}")
    print(f"📋 LISTA DE VÍDEOS (mais antigo → mais recente)")
    print(f"{'='*50}\n")

    for i, video in enumerate(videos, 1):
        print(f"{i:3}. {video['title']}")
        print(f"     {video['url']}\n")

    # Extrai handle do canal da URL
    handle_match = re.search(r'@([\w-]+)', channel_url)
    handle = handle_match.group(1) if handle_match else channel_name.replace(' ', '_')

    base_path = Path("/Users/neog/Downloads") / handle

    # === TXT ===
    txt_file = f"{base_path}_videos.txt"
    with open(txt_file, 'w', encoding='utf-8') as f:
        f.write(f"Canal: {channel_name}\n")
        f.write(f"Handle: @{handle}\n")
        f.write(f"URL: {channel_url}\n")
        f.write(f"Total: {len(videos)} vídeos\n")
        f.write(f"Ordem: mais antigo → mais recente\n")
        f.write(f"{'='*50}\n\n")

        for i, video in enumerate(videos, 1):
            f.write(f"{i}. {video['title']}\n")
            f.write(f"   {video['url']}\n\n")

    # === CSV ===
    csv_file = f"{base_path}_videos.csv"
    with open(csv_file, 'w', encoding='utf-8', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(['#', 'Título', 'URL', 'Video ID'])
        for i, video in enumerate(videos, 1):
            # Extrai video ID da URL
            video_id_match = re.search(r'v=([a-zA-Z0-9_-]+)', video['url'])
            video_id = video_id_match.group(1) if video_id_match else ''
            writer.writerow([i, video['title'], video['url'], video_id])

    # === JSON ===
    json_file = f"{base_path}_videos.json"
    json_data = {
        'channel': {
            'name': channel_name,
            'handle': f'@{handle}',
            'url': channel_url
        },
        'metadata': {
            'total_videos': len(videos),
            'order': 'oldest_to_newest',
            'extracted_at': time.strftime('%Y-%m-%d %H:%M:%S')
        },
        'videos': []
    }

    for i, video in enumerate(videos, 1):
        video_id_match = re.search(r'v=([a-zA-Z0-9_-]+)', video['url'])
        video_id = video_id_match.group(1) if video_id_match else ''
        json_data['videos'].append({
            'index': i,
            'title': video['title'],
            'url': video['url'],
            'video_id': video_id
        })

    with open(json_file, 'w', encoding='utf-8') as f:
        json.dump(json_data, f, ensure_ascii=False, indent=2)

    print(f"\n💾 Arquivos salvos:")
    print(f"   📄 TXT:  {txt_file}")
    print(f"   📊 CSV:  {csv_file}")
    print(f"   📋 JSON: {json_file}")
    print(f"\n📊 Total: {len(videos)} vídeos")

if __name__ == "__main__":
    main()


     1→-- Scrape Predictive History YouTube Channel
     2→-- Opens Safari, scrolls to load all videos, extracts video data
     3→
     4→tell application "Safari"
     5→    activate
     6→
     7→    -- Open the channel videos page
     8→    open location "https://www.youtube.com/@PredictiveHistory/videos"
     9→    delay 5
    10→
    11→    -- Scroll to load all videos (YouTube uses lazy loading)
    12→    repeat 30 times
    13→        do JavaScript "window.scrollTo(0, document.body.scrollHeight)" in document 1
    14→        delay 1.5
    15→    end repeat
    16→
    17→    -- Scroll back to top
    18→    do JavaScript "window.scrollTo(0, 0)" in document 1
    19→    delay 1
    20→
    21→    -- Extract video data
    22→    set videoData to do JavaScript "
    23→        const videos = [];
    24→        document.querySelectorAll('ytd-rich-item-renderer').forEach((el) => {
    25→            const titleEl = el.querySelector('#video-title');
    26→            const link = titleEl?.href || '';
    27→            const title = titleEl?.textContent?.trim() || '';
    28→            const metaEl = el.querySelector('#metadata-line');
    29→            const spans = metaEl?.querySelectorAll('span') || [];
    30→            const views = spans[0]?.textContent?.trim() || '';
    31→            const timeAgo = spans[1]?.textContent?.trim() || '';
    32→            const durationEl = el.querySelector('span.ytd-thumbnail-overlay-time-status-renderer');
    33→            const duration = durationEl?.textContent?.trim() || '';
    34→            const youtubeId = link.match(/v=([^&]+)/)?.[1] || '';
    35→
    36→            if (youtubeId) {
    37→                videos.push({
    38→                    title: title,
    39→                    youtubeId: youtubeId,
    40→                    duration: duration,
    41→                    views: views,
    42→                    url: link
    43→                });
    44→            }
    45→        });
    46→        JSON.stringify(videos, null, 2);
    47→    " in document 1
    48→
    49→    return videoData
    50→end tell
    51→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

-- Scrape Predictive History YouTube Channel
-- Opens Safari, scrolls to load all videos, extracts video data

tell application "Safari"
    activate

    -- Open the channel videos page
    open location "https://www.youtube.com/@PredictiveHistory/videos"
    delay 5

    -- Scroll to load all videos (YouTube uses lazy loading)
    repeat 30 times
        do JavaScript "window.scrollTo(0, document.body.scrollHeight)" in document 1
        delay 1.5
    end repeat

    -- Scroll back to top
    do JavaScript "window.scrollTo(0, 0)" in document 1
    delay 1

    -- Extract video data
    set videoData to do JavaScript "
        const videos = [];
        document.querySelectorAll('ytd-rich-item-renderer').forEach((el) => {
            const titleEl = el.querySelector('#video-title');
            const link = titleEl?.href || '';
            const title = titleEl?.textContent?.trim() || '';
            const metaEl = el.querySelector('#metadata-line');
            const spans = metaEl?.querySelectorAll('span') || [];
            const views = spans[0]?.textContent?.trim() || '';
            const timeAgo = spans[1]?.textContent?.trim() || '';
            const durationEl = el.querySelector('span.ytd-thumbnail-overlay-time-status-renderer');
            const duration = durationEl?.textContent?.trim() || '';
            const youtubeId = link.match(/v=([^&]+)/)?.[1] || '';

            if (youtubeId) {
                videos.push({
                    title: title,
                    youtubeId: youtubeId,
                    duration: duration,
                    views: views,
                    url: link
                });
            }
        });
        JSON.stringify(videos, null, 2);
    " in document 1

    return videoData
end tell


Perfeito! Tenho todas as informações necessárias. Vou agora buscar por outros exemplos de automação com múltiplas páginas para completar a análise:


find /Users/neog -name "*automation*" -type f 2>/dev/null | grep -v ".git" | head -15

Find automation scripts


     1→-- AppleScript to scrape all videos from a YouTube channel
     2→-- Usage: osascript scrape-youtube-channel.applescript
     3→-- Output: ~/predictive-history-videos.json
     4→
     5→property channelURL : "https://www.youtube.com/@PredictiveHistory/videos"
     6→property outputFile : "predictive-history-videos.json"
     7→
     8→on run
     9→	-- Open Safari and navigate to the channel
    10→	tell application "Safari"
    11→		activate
    12→
    13→		-- Check if there's already a window, if not create one
    14→		if (count of windows) = 0 then
    15→			make new document
    16→		end if
    17→
    18→		-- Navigate to the channel videos page
    19→		set URL of document 1 to channelURL
    20→
    21→		-- Wait for initial page load
    22→		delay 5
    23→
    24→		-- Enable "Allow JavaScript from Apple Events" in Safari's Develop menu first!
    25→		-- Safari > Develop > Allow JavaScript from Apple Events
    26→
    27→		-- Scroll down multiple times to load all videos (YouTube uses lazy loading)
    28→		-- Channel has 113 videos, need to scroll many times
    29→		display notification "Scrolling to load all 113 videos (this takes ~2 minutes)..." with title "YouTube Scraper"
    30→
    31→		-- First scroll: quick passes to trigger lazy loading
    32→		repeat 60 times
    33→			try
    34→				do JavaScript "window.scrollTo(0, document.body.scrollHeight)" in document 1
    35→			on error errMsg
    36→				display dialog "JavaScript Error: " & errMsg & return & return & "Make sure 'Allow JavaScript from Apple Events' is enabled in Safari's Develop menu." buttons {"OK"} default button "OK"
    37→				return
    38→			end try
    39→			delay 1
    40→		end repeat
    41→
    42→		-- Wait for content to load
    43→		delay 3
    44→
    45→		-- Second scroll: slower passes to ensure all content loads
    46→		repeat 30 times
    47→			do JavaScript "window.scrollTo(0, document.body.scrollHeight)" in document 1
    48→			delay 2
    49→		end repeat
    50→
    51→		-- Wait for final content to load
    52→		delay 5
    53→
    54→		-- Scroll back to top
    55→		do JavaScript "window.scrollTo(0, 0)" in document 1
    56→		delay 1
    57→
    58→		-- Extract video data using JavaScript
    59→		display notification "Extracting video data..." with title "YouTube Scraper"
    60→
    61→		set jsCode to "
    62→			(() => {
    63→				const videos = [];
    64→				const videoElements = document.querySelectorAll('ytd-rich-item-renderer, ytd-grid-video-renderer');
    65→
    66→				videoElements.forEach((el, index) => {
    67→					try {
    68→						// Get title and link
    69→						const titleEl = el.querySelector('#video-title-link, #video-title');
    70→						if (!titleEl) return;
    71→
    72→						const link = titleEl.href || titleEl.closest('a')?.href || '';
    73→						const title = titleEl.textContent?.trim() || '';
    74→
    75→						// Extract YouTube video ID from URL
    76→						const videoIdMatch = link.match(/[?&]v=([^&]+)/) || link.match(/\\/shorts\\/([^?]+)/);
    77→						const youtubeId = videoIdMatch ? videoIdMatch[1] : '';
    78→
    79→						// Get metadata (views, time ago)
    80→						const metadataLine = el.querySelector('#metadata-line');
    81→						const viewsEl = metadataLine?.querySelector('span.ytd-video-meta-block, span.inline-metadata-item');
    82→						const views = viewsEl?.textContent?.trim() || '';
    83→
    84→						// Get duration
    85→						const durationEl = el.querySelector('ytd-thumbnail-overlay-time-status-renderer span, .ytd-thumbnail-overlay-time-status-renderer');
    86→						const duration = durationEl?.textContent?.trim() || '';
    87→
    88→						// Get thumbnail
    89→						const thumbnailEl = el.querySelector('img#img');
    90→						const thumbnail = thumbnailEl?.src || '';
    91→
    92→						if (youtubeId && title) {
    93→							videos.push({
    94→								title: title,
    95→								url: 'https://www.youtube.com/watch?v=' + youtubeId,
    96→								youtubeId: youtubeId,
    97→								duration: duration,
    98→								views: views,
    99→								thumbnail: thumbnail
   100→							});
   101→						}
   102→					} catch (e) {
   103→						console.error('Error processing video element:', e);
   104→					}
   105→				});
   106→
   107→				// IMPORTANT: Reverse to get oldest first (YouTube shows newest first)
   108→				videos.reverse();
   109→
   110→				// Add order numbers after reversing (1 = oldest)
   111→				videos.forEach((video, index) => {
   112→					video.order = index + 1;
   113→				});
   114→
   115→				return JSON.stringify(videos, null, 2);
   116→			})();
   117→		"
   118→
   119→		set videoDataJSON to do JavaScript jsCode in document 1
   120→
   121→		-- Check if we got data
   122→		if videoDataJSON is "" or videoDataJSON is "[]" then
   123→			display dialog "No videos found. The page structure may have changed or videos haven't loaded properly." buttons {"OK"} default button "OK"
   124→			return
   125→		end if
   126→
   127→	end tell
   128→
   129→	-- Save to file
   130→	set homePath to POSIX path of (path to home folder)
   131→	set filePath to homePath & outputFile
   132→
   133→	try
   134→		set fileRef to open for access (POSIX file filePath) with write permission
   135→		set eof fileRef to 0
   136→		write videoDataJSON to fileRef as «class utf8»
   137→		close access fileRef
   138→
   139→		display notification "Saved to ~/" & outputFile with title "YouTube Scraper Complete"
   140→		display dialog "Successfully extracted videos!" & return & return & "Saved to: " & filePath buttons {"Open File", "OK"} default button "OK"
   141→
   142→		if button returned of result = "Open File" then
   143→			do shell script "open " & quoted form of filePath
   144→		end if
   145→
   146→	on error errMsg
   147→		try
   148→			close access fileRef
   149→		end try
   150→		display dialog "Error saving file: " & errMsg buttons {"OK"} default button "OK"
   151→	end try
   152→
   153→end run
   154→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

-- AppleScript to scrape all videos from a YouTube channel
-- Usage: osascript scrape-youtube-channel.applescript
-- Output: ~/predictive-history-videos.json

property channelURL : "https://www.youtube.com/@PredictiveHistory/videos"
property outputFile : "predictive-history-videos.json"

on run
	-- Open Safari and navigate to the channel
	tell application "Safari"
		activate

		-- Check if there's already a window, if not create one
		if (count of windows) = 0 then
			make new document
		end if

		-- Navigate to the channel videos page
		set URL of document 1 to channelURL

		-- Wait for initial page load
		delay 5

		-- Enable "Allow JavaScript from Apple Events" in Safari's Develop menu first!
		-- Safari > Develop > Allow JavaScript from Apple Events

		-- Scroll down multiple times to load all videos (YouTube uses lazy loading)
		-- Channel has 113 videos, need to scroll many times
		display notification "Scrolling to load all 113 videos (this takes ~2 minutes)..." with title "YouTube Scraper"

		-- First scroll: quick passes to trigger lazy loading
		repeat 60 times
			try
				do JavaScript "window.scrollTo(0, document.body.scrollHeight)" in document 1
			on error errMsg
				display dialog "JavaScript Error: " & errMsg & return & return & "Make sure 'Allow JavaScript from Apple Events' is enabled in Safari's Develop menu." buttons {"OK"} default button "OK"
				return
			end try
			delay 1
		end repeat

		-- Wait for content to load
		delay 3

		-- Second scroll: slower passes to ensure all content loads
		repeat 30 times
			do JavaScript "window.scrollTo(0, document.body.scrollHeight)" in document 1
			delay 2
		end repeat

		-- Wait for final content to load
		delay 5

		-- Scroll back to top
		do JavaScript "window.scrollTo(0, 0)" in document 1
		delay 1

		-- Extract video data using JavaScript
		display notification "Extracting video data..." with title "YouTube Scraper"

		set jsCode to "
			(() => {
				const videos = [];
				const videoElements = document.querySelectorAll('ytd-rich-item-renderer, ytd-grid-video-renderer');

				videoElements.forEach((el, index) => {
					try {
						// Get title and link
						const titleEl = el.querySelector('#video-title-link, #video-title');
						if (!titleEl) return;

						const link = titleEl.href || titleEl.closest('a')?.href || '';
						const title = titleEl.textContent?.trim() || '';

						// Extract YouTube video ID from URL
						const videoIdMatch = link.match(/[?&]v=([^&]+)/) || link.match(/\\/shorts\\/([^?]+)/);
						const youtubeId = videoIdMatch ? videoIdMatch[1] : '';

						// Get metadata (views, time ago)
						const metadataLine = el.querySelector('#metadata-line');
						const viewsEl = metadataLine?.querySelector('span.ytd-video-meta-block, span.inline-metadata-item');
						const views = viewsEl?.textContent?.trim() || '';

						// Get duration
						const durationEl = el.querySelector('ytd-thumbnail-overlay-time-status-renderer span, .ytd-thumbnail-overlay-time-status-renderer');
						const duration = durationEl?.textContent?.trim() || '';

						// Get thumbnail
						const thumbnailEl = el.querySelector('img#img');
						const thumbnail = thumbnailEl?.src || '';

						if (youtubeId && title) {
							videos.push({
								title: title,
								url: 'https://www.youtube.com/watch?v=' + youtubeId,
								youtubeId: youtubeId,
								duration: duration,
								views: views,
								thumbnail: thumbnail
							});
						}
					} catch (e) {
						console.error('Error processing video element:', e);
					}
				});

				// IMPORTANT: Reverse to get oldest first (YouTube shows newest first)
				videos.reverse();

				// Add order numbers after reversing (1 = oldest)
				videos.forEach((video, index) => {
					video.order = index + 1;
				});

				return JSON.stringify(videos, null, 2);
			})();
		"

		set videoDataJSON to do JavaScript jsCode in document 1

		-- Check if we got data
		if videoDataJSON is "" or videoDataJSON is "[]" then
			display dialog "No videos found. The page structure may have changed or videos haven't loaded properly." buttons {"OK"} default button "OK"
			return
		end if

	end tell

	-- Save to file
	set homePath to POSIX path of (path to home folder)
	set filePath to homePath & outputFile

	try
		set fileRef to open for access (POSIX file filePath) with write permission
		set eof fileRef to 0
		write videoDataJSON to fileRef as «class utf8»
		close access fileRef

		display notification "Saved to ~/" & outputFile with title "YouTube Scraper Complete"
		display dialog "Successfully extracted videos!" & return & return & "Saved to: " & filePath buttons {"Open File", "OK"} default button "OK"

		if button returned of result = "Open File" then
			do shell script "open " & quoted form of filePath
		end if

	on error errMsg
		try
			close access fileRef
		end try
		display dialog "Error saving file: " & errMsg buttons {"OK"} default button "OK"
	end try

end run


Perfeito! Tenho toda a informação necessária. Agora vou elaborar um documento detalhado de análise:

## ANÁLISE COMPLETA: Instagram Story Viewers Extractor

Realizei uma análise profunda do script `/Users/neog/Scripts/tools/instagram-story-viewers.py` e de padrões relacionados. Aqui está a documentação estruturada:

---

### 1. ARQUITETURA GERAL DO SCRIPT

O script utiliza um padrão **AppleScript → JavaScript → Safari** para extrair dados dinâmicos da web:

```
┌─────────────────────┐
│  Python Script      │  (Orquestrador)
│  instagram-story... │
└──────────┬──────────┘
           │ subprocess.run(['osascript', '-e', script])
           ▼
┌─────────────────────┐
│  AppleScript        │  (Interface do SO)
│  (via osascript)    │  Comunica com Safari
└──────────┬──────────┘
           │ tell application "Safari"
           ▼
┌─────────────────────┐
│  Safari DOM         │  (Extração de dados)
│  JavaScript         │  Manipula página em tempo real
└─────────────────────┘
```

**Fluxo Principal (linhas 401-490):**
1. Validação do Safari (`check_safari_devtools`)
2. Verificação de URL (`get_current_url`)
3. Extração inicial de viewers (`extract_story_viewers`)
4. Menu interativo (save, scroll, monitor)

---

### 2. COMO FUNCIONA A EXTRAÇÃO VIA JAVASCRIPT

**Função: `extract_story_viewers()` (linhas 74-194)**

O código JavaScript busca perfis usando **dois métodos paralelos**:

#### Método 1: Busca por Imagens de Perfil (Linhas 82-131)
```javascript
// Procura elementos que terminam em "'s profile picture"
let profilePics = document.querySelectorAll('img[alt$="\'s profile picture"]');

profilePics.forEach(img => {
    let alt = img.getAttribute('alt');  // Ex: "john_doe's profile picture"
    let match = alt.match(/^(.+)'s profile picture$/);
    let username = match[1];  // Ex: "john_doe"

    // Busca container pai para extrair nome completo
    let container = img.closest('div[role="button"]') || img.closest('a[href]');

    // Tira spans para encontrar nome completo
    let fullName = '';
    spans.forEach(span => {
        if (span.textContent !== username) {
            fullName = span.textContent;  // Nome real != username
        }
    });
});
```

**Dados Extraídos:**
- `username`: Handle (@username)
- `name`: Nome completo exibido
- `profile_url`: URL do perfil (https://instagram.com/{username})
- `profile_pic`: Imagem de perfil (img.src)

#### Método 2: Fallback por Links e Dialogs (Linhas 134-162)
Se o método 1 falhar, procura pela estrutura de diálogo do Instagram:
```javascript
let dialog = document.querySelector('div[role="dialog"]');
let links = dialog.querySelectorAll('a[href^="/"]');
// Filtra para evitar links de navegação (explore, direct, accounts, p, reel)
```

#### Remoção de Duplicatas (Linhas 164-172)
```javascript
let unique = [];
let seen = new Set();
viewers.forEach(v => {
    if (!seen.has(v.username)) {  // Usa username como chave única
        seen.add(v.username);
        unique.push(v);
    }
});
```

**Problema Identificado:** Se o Instagram tiver o mesmo usuario logado 2x (plataformas diferentes), será contado como 1. Solução: adicionar timestamp ou ID único da sessão.

---

### 3. MECANISMO DE SCROLL

**Função: `scroll_viewers_list()` (linhas 254-290)**

```python
def scroll_viewers_list(direction: str = "down"):
    scroll_amount = 500 if direction == "down" else -500
```

**JavaScript executado:**
```javascript
let dialog = document.querySelector('div[role="dialog"]');

// Tenta encontrar o div com overflow (scrollável)
let elements = dialog.querySelectorAll('div');
for (let el of elements) {
    if (el.scrollHeight > el.clientHeight) {  // Element tem conteúdo oculto
        el.scrollTop += 500;  // Rola 500px
        return "scrolled";
    }
}

// Fallback: rola a janela inteira
window.scrollBy(0, 500);
```

**Lógica do Loop de Scroll (linhas 315-346):**
```python
def extract_all_with_scroll(max_scrolls: int = 20):
    all_viewers = {}

    for i in range(max_scrolls):
        # 1. Extrai viewers visíveis
        viewers = extract_story_viewers()

        # 2. Conta novos encontrados
        new_count = 0
        for v in viewers:
            if v['username'] not in all_viewers:
                all_viewers[v['username']] = v
                new_count += 1

        # 3. Para se nenhum novo após 2+ scrolls
        if new_count == 0 and i > 2:
            break

        # 4. Aguarda carregamento
        scroll_viewers_list("down")
        time.sleep(0.8)  # IMPORTANTE: delay para rendering
```

**Problema Identificado:** O script assume que scrollar sempre carrega novos viewers. Se a lista é pequena, pode entrar em loop infinito. Melhoria: track altura do scroll ou usar `MutationObserver`.

---

### 4. SALVAMENTO DE DADOS

**Função: `save_viewers()` (linhas 292-313)**

```python
def save_viewers(viewers: list, story_id: str = "story"):
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

    # JSON com metadata
    json_file = OUTPUT_DIR / f"viewers_{story_id}_{timestamp}.json"
    json.dump({
        "extracted_at": datetime.now().isoformat(),
        "story_id": story_id,
        "count": len(viewers),
        "viewers": viewers
    }, f)

    # CSV para análise em planilha
    csv_file = OUTPUT_DIR / f"viewers_{story_id}_{timestamp}.csv"
    writer = csv.DictWriter(f, fieldnames=['username', 'name', 'profile_url', 'profile_pic'])
    writer.writeheader()
    writer.writerows(viewers)
```

**Estrutura de Dados:**
```json
{
    "extracted_at": "2025-01-02T17:43:00",
    "story_id": "story",
    "count": 142,
    "viewers": [
        {
            "username": "john_doe",
            "name": "John Doe",
            "profile_url": "https://instagram.com/john_doe",
            "profile_pic": "https://instagram.com/...image..."
        }
    ]
}
```

**Diretório de Saída:** `~/Documents/instagram-data/`

---

### 5. MODO MONITOR (Tracking em Tempo Real)

**Função: `monitor_mode()` (linhas 348-385)**

```python
def monitor_mode(interval: int = 30):
    all_viewers = {}
    session_start = datetime.now()

    try:
        while True:
            viewers = extract_story_viewers()

            # Marca primeira visualização
            new_this_round = []
            for v in viewers:
                if v['username'] not in all_viewers:
                    all_viewers[v['username']] = v
                    v['first_seen'] = datetime.now().isoformat()  # MARCA TEMPO
                    new_this_round.append(v)
                    print(f"✨ Novo: @{v['username']}")

            time.sleep(interval)  # 30 segundos entre cheques

    except KeyboardInterrupt:
        # Ao parar (Ctrl+C), salva dados
        save_viewers(list(all_viewers.values()), "monitor")
```

**Funcionalidade:** Detecta novos viewers em tempo real + notificações.

---

### 6. TRATAMENTO DE ERROS E FALLBACKS

| Ponto | Fallback | Código |
|-------|----------|--------|
| Extração JS falha | Parse texto DOM | `parse_viewers_from_text()` linhas 219-252 |
| Nenhum viewer encontrado | Retry com scroll | linhas 473-487 |
| AppleScript erro | Exibe mensagem STDERR | linhas 37-38 |
| Safari fechado | Valida janelas abertas | `check_safari_devtools()` |

---

### 7. PONTOS-CHAVE PARA INTEGRAÇÃO MULTI-STORIES

Para adaptar o script para navegar entre múltiplos stories automaticamente:

#### 7.1 **Estrutura Necessária**

```python
def navigate_to_next_story():
    """Avança para o próximo story"""
    js_code = '''
    (function() {
        // Procura botão "next" ou arrow_forward no story player
        let nextBtn = document.querySelector('button[aria-label*="Next"]') ||
                      document.querySelector('[aria-label="Next story"]');

        if (nextBtn) {
            nextBtn.click();
            return 'moved_next';
        }

        // Fallback: arrow key (direita)
        let event = new KeyboardEvent('keydown', {key: 'ArrowRight'});
        document.dispatchEvent(event);
        return 'arrow_key';
    })();
    '''
    # Executar e retornar resultado
```

#### 7.2 **Loop de Stories Múltiplas**

```python
def extract_all_stories(max_stories: int = 10):
    """Extrai viewers de múltiplos stories"""
    all_story_data = {}

    for story_num in range(max_stories):
        # 1. Clica para ver viewers
        click_viewers_button()
        time.sleep(1)

        # 2. Extrai todos os viewers com scroll
        viewers = extract_all_with_scroll(max_scrolls=15)
        all_story_data[f"story_{story_num}"] = {
            "viewers": viewers,
            "count": len(viewers),
            "extracted_at": datetime.now().isoformat()
        }

        # 3. Navega para próximo story
        result = navigate_to_next_story()
        if result == 'no_more_stories':
            break

        time.sleep(2)  # Aguarda carregar

    return all_story_data
```

#### 7.3 **Detecção de Fim da Sequência**

```python
def is_story_available():
    """Verifica se há story visível"""
    js_code = '''
    (function() {
        // Procura elemento principal do story
        let storyPlayer = document.querySelector('[role="region"][aria-label*="Story"]') ||
                          document.querySelector('div[class*="storyPlayer"]');
        return !!storyPlayer;
    })();
    '''
```

#### 7.4 **Persistência Entre Stories**

```python
# Usar dicionário com story_id como chave
all_data = {
    "story_1_20250102_143000": {
        "timestamp": "2025-01-02T14:30:00",
        "viewers": [...],
        "scroll_count": 8,
        "unique_count": 145
    },
    "story_2_20250102_143500": {
        "timestamp": "2025-01-02T14:35:00",
        "viewers": [...],
        "scroll_count": 12,
        "unique_count": 89
    }
}
```

---

### 8. COMPARAÇÃO COM PADRÕES DE OUTRAS AUTOMAÇÕES

Comparei com `youtube-channel-extractor.py` e `scrape-youtube-channel.applescript`:

| Aspecto | Instagram Stories | YouTube Channel |
|---------|-------------------|-----------------|
| **Detecção de Fim** | `new_count == 0 após scrolls` | `document.scrollHeight === lastHeight` |
| **Múltiplas Páginas** | ❌ Não implementado | ✅ Loop com contador |
| **Lazy Loading** | CSS Dialog detecta | Window scrollHeight detecta |
| **Estrutura de Saída** | `story_id + timestamp` | `channel_name + timestamp` |
| **Delay entre Ações** | 0.8s (curto) | 1-2s (médio) |

---

### 9. LIMITAÇÕES E RECOMENDAÇÕES

**Limitações Identificadas:**

1. **Sem Contexto de Story ID**
   - Não diferencia qual story está sendo scrapeado
   - Solução: Extrair URL do story ou screenshot

2. **Sem Detecção de Instagram Web vs App**
   - Só funciona com Safari (navegador)
   - App mobile não suporta

3. **Sensibilidade a Mudanças UI**
   - Seletores CSS hardcoded podem quebrar
   - Solução: Adicionar versionamento e fallbacks adicionais

4. **Rate Limiting não Contemplado**
   - Instagram pode bloquear extractors muito agressivos
   - Recomendação: Aumentar `time.sleep()` para 2-3s

5. **Duplicatas por Sessão**
   - Não rastreia usuário que viu múltiplos stories
   - Seria preciso cross-reference por username global

**Recomendações para Produção:**

```python
# 1. Adicionar logging estruturado
import logging
logging.basicConfig(filename='instagram-extract.log', level=logging.INFO)

# 2. Rate limiting
def safe_scroll(delay: float = 2.0):
    time.sleep(delay)  # Pausa entre ações

# 3. Validação de dados
def validate_viewer(v: dict) -> bool:
    return 'username' in v and '@' not in v['username']

# 4. Retry com backoff exponencial
def run_with_retry(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func()
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)  # 1s, 2s, 4s
```

---

### RESUMO DOS PONTOS DE INTEGRAÇÃO

```
┌─ INICIALIZAÇÃO ─────────────────┐
│ check_safari_devtools()         │
│ get_current_url()               │
└─────────────────┬───────────────┘
                  ▼
┌─ PRIMEIRA EXTRAÇÃO ─────────────┐
│ extract_story_viewers()         │
│ [se falhar] parse_viewers_from_text()│
└─────────────────┬───────────────┘
                  ▼
┌─ OPÇÃO 1: SCROLL AUTOMÁTICO ────┐
│ extract_all_with_scroll()       │
│ ├─ loop: scroll_viewers_list()  │
│ ├─ cada iteração: extract()     │
│ └─ para quando new_count=0      │
└─────────────────┬───────────────┘
                  ▼
┌─ SALVAMENTO ────────────────────┐
│ save_viewers()                  │
│ ├─ JSON: /Documents/instagram.. │
│ └─ CSV: /Documents/instagram..  │
└────────────────────────────────┘

┌─ OPÇÃO 2: MODO MONITOR ─────────┐
│ monitor_mode(interval=30)       │
│ └─ loop: detecta novos + notif  │
└────────────────────────────────┘

┌─ PARA MULTI-STORIES ────────────┐
│ [NOVO] navigate_to_next_story()  │
│ [NOVO] is_story_available()      │
│ [NOVO] extract_all_stories()     │
└────────────────────────────────┘
```

---

Essa análise fornece a base técnica completa para entender a lógica do script e as adaptações necessárias para multi-stories!