// ==UserScript== // @name Obsidian Web Clipper com Classificação OpenRouter (Revisado) // @namespace http://tampermonkey.net/ // @version 1.1 // @description Clipa conteúdo para Obsidian com frontmatter gerado via OpenRouter após clique no botão // @author Grok // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_addValueChangeListener // @connect gist.github.com // @connect openrouter.ai // ==/UserScript== (function() { 'use strict'; // === CONFIGURAÇÕES - EDITE AQUI === const API_KEY = 'sua-api-key-aqui'; // Coloque sua OpenRouter API key const MODEL = 'openai/gpt-4o-mini'; // Ex: 'openai/gpt-4o-mini', 'anthropic/claude-3-haiku' const GIST_URL = 'https://gist.github.com/camillanapoles/d296ca7801eb7adf7f212f723834cc40/raw/obsidian-web-clipper-template.md'; // URL do Gist contendo o prompt/template // === FIM DAS CONFIGURAÇÕES === // Armazena temporariamente a classificação após o OpenRouter responder let classificacaoTemp = null; // Formata data atual como YYYY-MM-DD const hoje = new Date().toISOString().split('T')[0]; // Ex.: 2025-09-28 // Função para formatar título como nome de arquivo function formatarParaFilename(texto) { return texto .toLowerCase() .replace(/[^\w\s-]/g, '') // Remove caracteres especiais .replace(/\s+/g, '-') // Espaços viram hífens .replace(/-+/g, '-') // Remove hífens duplicados .substring(0, 50); // Limita a 50 caracteres } // Função para extrair título e conteúdo da página, limpando números de linha de code blocks function extrairConteudo() { const title = document.querySelector('title')?.textContent || document.title || 'Sem título'; let content = ''; // Tenta obter conteúdo da seleção if (window.getSelection) { const selection = window.getSelection(); if (selection.toString().trim()) { // Se houver seleção, usa-a content = selection.toString(); } else { // Se não houver seleção, tenta extrair o conteúdo principal da página // Tenta encontrar elementos comuns de conteúdo const contentSelectors = [ 'article', 'main', '[role="main"]', '.content', '#content', '.post', '.entry-content', 'main-content', '[class*="article"]', '[class*="post"]' ]; let contentElement = null; for (const selector of contentSelectors) { contentElement = document.querySelector(selector); if (contentElement) break; } if (contentElement) { // Clona o conteúdo para não alterar a página original const clone = contentElement.cloneNode(true); // Remove scripts, estilos, etc., para evitar lixo const unwantedSelectors = ['script', 'style', 'nav', 'header', 'footer', 'aside', 'iframe', 'img', 'svg']; unwantedSelectors.forEach(unwantedSel => { clone.querySelectorAll(unwantedSel).forEach(el => el.remove()); }); // Processa os blocos de código para remover números de linha clone.querySelectorAll('pre code').forEach(codeBlock => { let codeText = codeBlock.textContent; // Regex para identificar linhas que parecem conter apenas números (possivelmente com ':') // Ex: "1", "10 ", " 23: ", "456 |" // Este é um padrão genérico, pode precisar de ajustes para sites específicos const lines = codeText.split('\n'); const cleanedLines = lines.filter(line => { // Verifica se a linha é composta *apenas* por espaços em branco e números (e possíveis caracteres de separação) // Se for, remove; caso contrário, mantém return !/^\s*\d+\s*[:|>]*\s*$/.test(line.trim()); }); // Atualiza o texto do bloco com as linhas limpas codeBlock.textContent = cleanedLines.join('\n'); }); // Obtém o texto limpo do conteúdo content = clone.innerText || clone.textContent || ''; } else { // Se não encontrar conteúdo estruturado, tenta o body content = document.body.innerText || document.body.textContent || ''; } } } else if (document.selection && document.selection.type !== 'Control') { // Para navegadores antigos (IE) content = document.selection.createRange().text; } // Limita o conteúdo enviado para evitar prompts muito longos // Ajuste o limite conforme necessário const limite = 5000; // Caracteres if (content.length > limite) { content = content.substring(0, limite) + '... [CONTEÚDO TRUNCADO]'; } return { title, content: content.trim() }; } // Função para obter o prompt/template do Gist function obterPromptDoGist() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: GIST_URL, onload: function(response) { if (response.status >= 200 && response.status < 300) { resolve(response.responseText); } else { reject(new Error(`Erro ao carregar Gist: ${response.status}`)); } }, onerror: function(error) { reject(new Error(`Erro de rede ao carregar Gist: ${error.error}`)); } }); }); } // Função para chamar OpenRouter API async function gerarClassificacao(conteudo, templatePrompt) { // Substitui placeholders no template com os dados extraídos // Certifique-se que o template Gist tenha placeholders como {{titulo}} e {{conteudo}} let prompt = templatePrompt .replace(/\{\{titulo\}\}/g, conteudo.title) .replace(/\{\{conteudo\}\}/g, conteudo.content) .replace(/\{\{data\}\}/g, hoje); // Substitui {{data}} pela data atual console.log("Prompt enviado para OpenRouter:", prompt); // Para debug const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { // Corrigido URL method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', 'HTTP-Referer': window.location.href, // Adicionado referer 'X-Title': 'Classificador Grok', }, body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }], temperature: 0.7, max_tokens: 500, // Ajustado para permitir mais tokens de resposta }), }); if (!response.ok) { const errorDetails = await response.text(); throw new Error(`Erro API: ${response.status} - ${errorDetails}`); } const data = await response.json(); const respostaRaw = data.choices[0]?.message?.content?.trim() || '{}'; console.log("Resposta bruta do OpenRouter:", respostaRaw); // Para debug // Tenta parsear a resposta como JSON let resultado; try { // Tenta extrair o JSON da resposta, que pode conter texto extra const jsonMatch = respostaRaw.match(/```json\s*([\s\S]*?)\s*```|```([\s\S]*?)\s*```|([\s\S]*)/); const jsonString = (jsonMatch && (jsonMatch[1] || jsonMatch[2] || jsonMatch[3]))?.trim(); if (jsonString) { // Remove o ```json ou ``` do início/fim se necessário const cleanJsonString = jsonString.replace(/^```(json)?\s*|\s*```$/g, '').trim(); resultado = JSON.parse(cleanJsonString); } else { throw new Error("Nenhum conteúdo JSON encontrado na resposta."); } } catch (e) { console.error("Erro ao parsear JSON da resposta:", e); console.error("Resposta recebida:", respostaRaw); throw new Error("Erro ao processar a resposta do OpenRouter. Verifique o formato do JSON."); } return { tipo: resultado.tipo || 'artigo', area: resultado.area || 'geral', subarea: resultado.subarea || 'geral', tags: Array.isArray(resultado.tags) ? resultado.tags : (resultado.tags?.split(',').map(tag => tag.trim()).filter(tag => tag) || []), filename: resultado.filename || `${resultado.area || 'geral'}/${resultado.subarea || 'geral'}/${resultado.tipo || 'artigo'}/${hoje}-${formatarParaFilename(conteudo.title)}.md`, title_suggested: resultado.title_suggested || conteudo.title.substring(0, 100), vault: resultado.vault || '', // Adiciona vault se o template a retornar // Adicione outros campos conforme seu template Gist retornar }; } // Função para abrir o Obsidian com frontmatter e conteúdo function abrirObsidianComFrontmatter(classificacao, conteudoOriginal) { let title = prompt('Digite o título para a nota Obsidian', classificacao.title_suggested); if (title == null) { // Usuário cancelou console.log("Usuário cancelou a criação da nota."); return; } // Monta o frontmatter const frontmatter = `--- tipo: ${classificacao.tipo} area: ${classificacao.area} subarea: ${classificacao.subarea} tags: [${classificacao.tags.join(', ')}] filename: ${classificacao.filename} title_suggested: ${classificacao.title_suggested} url: "${window.location.href}" date: "${hoje}" ${classificacao.vault ? `vault: "${classificacao.vault}"\n` : ''}// Outros campos podem ser adicionados aqui --- \n${conteudoOriginal.content}`; // Monta a URL do Obsidian let obsidianUrl = `obsidian://new?name=${encodeURIComponent(title)}&content=${encodeURIComponent(frontmatter)}`; // Adiciona vault à URL se disponível e desejado if (classificacao.vault) { obsidianUrl += `&vault=${encodeURIComponent(classificacao.vault)}`; } console.log("URL do Obsidian gerada:", obsidianUrl); // Para debug window.location.href = obsidianUrl; } // Função principal: Gera classificação e exibe popup async function processarClassificacao() { console.log("Iniciando extração de conteúdo..."); const conteudo = extrairConteudo(); console.log("Conteúdo extraído (primeiros 200 chars):", conteudo.content.substring(0, 200) + "..."); try { console.log("Carregando prompt do Gist..."); const templatePrompt = await obterPromptDoGist(); console.log("Prompt do Gist carregado."); console.log("Gerando classificação via OpenRouter..."); const classificacao = await gerarClassificacao(conteudo, templatePrompt); console.log("Classificação gerada:", classificacao); // Armazena temporariamente classificacaoTemp = classificacao; // Exibe o popup com a classificação e o botão OK exibirClassificacaoPopup(classificacao, conteudo); } catch (error) { console.error('Erro no processo de classificação:', error); alert(`Erro: ${error.message}`); } } // Exibe classificação em popup com botão OK function exibirClassificacaoPopup(classificacao, conteudoOriginal) { const oldPopup = document.getElementById('classificacao-popup'); if (oldPopup) oldPopup.remove(); // Remove popup anterior se existir const popup = document.createElement('div'); popup.id = 'classificacao-popup'; popup.innerHTML = `
Tipo: ${classificacao.tipo}
Área: ${classificacao.area}
Subárea: ${classificacao.subarea}
Tags (${classificacao.tags.length}): ${classificacao.tags.join(', ')}
Filename: ${classificacao.filename}
Título Sugerido: ${classificacao.title_suggested}
${classificacao.vault ? `Vault: ${classificacao.vault}
` : ''} `; popup.style.cssText = ` position: fixed; top: 20%; left: 50%; transform: translateX(-50%); background: white; border: 1px solid #ccc; padding: 20px; z-index: 10000; box-shadow: 0 4px 8px rgba(0,0,0,0.2); border-radius: 8px; max-width: 400px; font-family: Arial, sans-serif; `; // Estilo para os botões const style = document.createElement('style'); style.textContent = ` #classificacao-popup button { margin: 5px 2px; padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } #ok-classificacao-btn { background: #4CAF50; color: white; } #close-classificacao-btn { background: #f44336; color: white; } `; document.head.appendChild(style); document.body.appendChild(popup); // Event listeners para os botões document.getElementById('ok-classificacao-btn').addEventListener('click', () => { if (classificacaoTemp) { // Confirma que a classificação ainda está disponível popup.remove(); abrirObsidianComFrontmatter(classificacaoTemp, conteudoOriginal); classificacaoTemp = null; // Limpa a variável temporária } else { alert("Erro: Classificação não encontrada. Tente novamente."); } }); document.getElementById('close-classificacao-btn').addEventListener('click', () => { popup.remove(); classificacaoTemp = null; // Limpa a variável temporária ao fechar }); } // Função para criar o botão principal function criarBotaoClassificar() { const botao = document.createElement('button'); botao.id = 'btn-classificar-conteudo'; // ID para facilitar remoção/busca se necessário botao.innerText = 'Classificar Conteúdo'; botao.style.cssText = ` position: fixed; bottom: 50px; right: 10px; z-index: 10000; padding: 10px 15px; background: #2196F3; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); `; botao.addEventListener('click', processarClassificacao); document.body.appendChild(botao); } // Inicializa o script: apenas cria o botão if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', criarBotaoClassificar); } else { criarBotaoClassificar(); } })();