perplexity-notebooklm 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Extensão de ESCRITA do NotebookLM (add_source via browser). Fase 3."""
@@ -0,0 +1,130 @@
1
+ """Escrita no NotebookLM: adiciona uma fonte (texto colado) a um notebook.
2
+
3
+ Anexa a um Chrome REAL já logado (--remote-debugging-port) — ver browser.py.
4
+ Por que não login automatizado: o Google bloqueia o login sob automação com
5
+ loop de challenge. Anexar numa sessão já logada contorna isso.
6
+
7
+ Ação NÃO-reversível na conta Google → o skill SEMPRE pede checkpoint humano
8
+ antes de chamar isto.
9
+
10
+ Seletores da UI do NotebookLM (pt-BR/en), validados ao vivo. Se a UI mudar,
11
+ ajuste só SELECTORS aqui.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ NOTEBOOKLM_HOME = "https://notebooklm.google.com"
19
+
20
+ SELECTORS = {
21
+ "add_source_button": [
22
+ "button[aria-label*='Adicionar fonte' i]",
23
+ "button[aria-label*='Add source' i]",
24
+ ],
25
+ # opção "Texto copiado" / "Copied text" / "Paste text".
26
+ # O rótulo é um <span>; o clicável é o botão/role ancestral. Miramos os dois
27
+ # (o JS-click no span borbulha pro handler do pai).
28
+ "paste_text_option": [
29
+ "//button[.//span[normalize-space()='Texto copiado' or normalize-space()='Copied text' or normalize-space()='Paste text']]",
30
+ "//*[@role='button'][.//*[normalize-space()='Texto copiado' or normalize-space()='Copied text' or normalize-space()='Paste text']]",
31
+ "//span[normalize-space()='Texto copiado' or normalize-space()='Copied text' or normalize-space()='Paste text']",
32
+ ],
33
+ "text_area": [
34
+ "textarea[aria-label='Texto colado']",
35
+ "textarea[placeholder*='Cole o texto' i]",
36
+ "textarea[placeholder*='Paste' i]",
37
+ ],
38
+ "insert_button": [
39
+ # Escopado ao diálogo p/ não casar um 'Inserir' de outro painel.
40
+ "//*[@role='dialog']//button[normalize-space()='Inserir' or normalize-space()='Insert']",
41
+ "//button[normalize-space()='Inserir' or normalize-space()='Insert']",
42
+ ],
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class AddSourceResult:
48
+ notebook_id: str
49
+ ok: bool
50
+ detail: str
51
+
52
+
53
+ def add_source(notebook_id: str, text: str, *, debug_port: int = 9222) -> AddSourceResult:
54
+ """Anexa ao Chrome logado, abre o notebook e injeta ``text`` como fonte.
55
+
56
+ Pré-requisito: Chrome aberto com --remote-debugging-port=<debug_port> e logado.
57
+ """
58
+ import time
59
+
60
+ from selenium.webdriver.common.by import By
61
+ from selenium.webdriver.support import expected_conditions as EC
62
+ from selenium.webdriver.support.ui import WebDriverWait
63
+
64
+ from .browser import attach_driver, is_authenticated
65
+
66
+ try:
67
+ driver = attach_driver(debug_port)
68
+ # Sempre recarrega: garante estado limpo (fecha diálogos/painéis abertos).
69
+ driver.get(f"{NOTEBOOKLM_HOME}/notebook/{notebook_id}")
70
+ time.sleep(6)
71
+ except Exception as exc:
72
+ # Chrome :9222 fechado/inacessível → erro limpo, sem traceback propagado.
73
+ return AddSourceResult(
74
+ notebook_id, False, f"Chrome :{debug_port} inacessível (abra o Chrome logado): {type(exc).__name__}"
75
+ )
76
+ if not is_authenticated(driver):
77
+ return AddSourceResult(notebook_id, False, "Sessão não autenticada no NotebookLM.")
78
+
79
+ wait = WebDriverWait(driver, 20)
80
+
81
+ def click(spec_list):
82
+ # Tenta clique normal; se o elemento existe mas está animando/coberto,
83
+ # cai pra clique via JS (robusto a overlays/transições da UI).
84
+ last = None
85
+ for sel in spec_list:
86
+ by = By.XPATH if sel.startswith("//") else By.CSS_SELECTOR
87
+ try:
88
+ wait.until(EC.element_to_be_clickable((by, sel))).click()
89
+ return
90
+ except Exception as e:
91
+ last = e
92
+ try:
93
+ el = wait.until(EC.presence_of_element_located((by, sel)))
94
+ driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
95
+ driver.execute_script("arguments[0].click();", el)
96
+ return
97
+ except Exception as e2:
98
+ last = e2
99
+ raise RuntimeError(f"Nenhum seletor clicável: {spec_list} ({last})")
100
+
101
+ def find(spec_list):
102
+ last = None
103
+ for sel in spec_list:
104
+ by = By.XPATH if sel.startswith("//") else By.CSS_SELECTOR
105
+ try:
106
+ return wait.until(EC.presence_of_element_located((by, sel)))
107
+ except Exception as e:
108
+ last = e
109
+ raise RuntimeError(f"Nenhum seletor presente: {spec_list} ({last})")
110
+
111
+ step = "init"
112
+ try:
113
+ step = "abrir 'Adicionar fonte'"
114
+ click(SELECTORS["add_source_button"])
115
+ time.sleep(3) # diálogo de opções renderiza
116
+ step = "escolher 'Texto copiado'"
117
+ click(SELECTORS["paste_text_option"])
118
+ time.sleep(2)
119
+ step = "localizar textarea"
120
+ area = find(SELECTORS["text_area"])
121
+ area.click()
122
+ area.send_keys(text)
123
+ time.sleep(1)
124
+ step = "clicar 'Inserir'"
125
+ click(SELECTORS["insert_button"])
126
+ time.sleep(4) # ingestão da fonte
127
+ return AddSourceResult(notebook_id, True, "Fonte adicionada (confirme visualmente).")
128
+ except Exception as exc:
129
+ # devolve o erro pro orquestrador com o passo que falhou; não silencia.
130
+ return AddSourceResult(notebook_id, False, f"Falha no passo [{step}]: {exc}")
@@ -0,0 +1,81 @@
1
+ """Launcher Selenium próprio para o NotebookLM.
2
+
3
+ Por que não usar o NotebookLMClient: seu fallback de Selenium normal NÃO amarra
4
+ o perfil persistente (--user-data-dir), então o login nunca persiste. Aqui
5
+ amarramos o perfil explicitamente. Selenium Manager resolve o chromedriver certo
6
+ para a versão instalada do Chrome (149+), sem undetected-chromedriver (que não
7
+ conecta nessa versão).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ NOTEBOOKLM_HOME = "https://notebooklm.google.com"
16
+ DEFAULT_PROFILE_DIR = os.environ.get(
17
+ "NOTEBOOKLM_PROFILE_DIR",
18
+ str(Path.home() / ".notebooklm-mcp" / "chrome-profile"),
19
+ )
20
+
21
+
22
+ def make_driver(profile_dir: str = DEFAULT_PROFILE_DIR, *, headless: bool = False):
23
+ """Cria um Chrome Selenium amarrado ao perfil persistente.
24
+
25
+ O perfil persistente é o que mantém a sessão Google entre execuções — é o
26
+ ponto que o fallback do notebooklm-mcp erra.
27
+ """
28
+ from selenium import webdriver
29
+ from selenium.webdriver.chrome.options import Options
30
+
31
+ Path(profile_dir).mkdir(parents=True, exist_ok=True)
32
+ opts = Options()
33
+ opts.add_argument(f"--user-data-dir={profile_dir}")
34
+ opts.add_argument("--no-first-run")
35
+ opts.add_argument("--no-default-browser-check")
36
+ opts.add_argument("--disable-blink-features=AutomationControlled")
37
+ opts.add_experimental_option("excludeSwitches", ["enable-automation"])
38
+ opts.add_experimental_option("useAutomationExtension", False)
39
+ if headless:
40
+ opts.add_argument("--headless=new")
41
+
42
+ driver = webdriver.Chrome(options=opts) # Selenium Manager baixa o driver certo
43
+ driver.execute_script(
44
+ "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
45
+ )
46
+ return driver
47
+
48
+
49
+ def attach_driver(debug_port: int = 9222):
50
+ """Anexa a um Chrome REAL já aberto com --remote-debugging-port.
51
+
52
+ Caminho robusto: o login acontece num Chrome normal (sem automação → o Google
53
+ não dispara o loop de challenge anti-bot). O Selenium só anexa na sessão já
54
+ logada. Pré-requisito: o usuário abriu o Chrome com a porta de debug e logou.
55
+ """
56
+ from selenium import webdriver
57
+ from selenium.webdriver.chrome.options import Options
58
+
59
+ opts = Options()
60
+ opts.add_experimental_option("debuggerAddress", f"127.0.0.1:{debug_port}")
61
+ driver = webdriver.Chrome(options=opts)
62
+ _focus_notebooklm(driver)
63
+ return driver
64
+
65
+
66
+ def _focus_notebooklm(driver) -> None:
67
+ """Foca a aba já aberta do NotebookLM (a sessão logada), não uma about:blank."""
68
+ for handle in driver.window_handles:
69
+ driver.switch_to.window(handle)
70
+ if "notebooklm.google.com" in driver.current_url:
71
+ return
72
+ # nenhuma aba NotebookLM: fica na primeira (será navegada por quem chamou)
73
+
74
+
75
+ def is_authenticated(driver) -> bool:
76
+ """Heurística: estamos no NotebookLM e não numa tela de login Google."""
77
+ url = driver.current_url
78
+ signed_out = (
79
+ "accounts.google.com" in url or "/login" in url or "signin" in url
80
+ )
81
+ return (not signed_out) and "notebooklm.google.com" in url
@@ -0,0 +1,49 @@
1
+ """Embeddings locais (model2vec, estático, SEM API key/torch) p/ dedup semântico.
2
+
3
+ Lazy + GRACIOSO: se model2vec/o modelo não estiverem disponíveis, `embed` retorna
4
+ None e o chamador cai no MinHash. O modelo (~30MB) baixa no primeiro uso.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ _MODEL_NAME = "minishlab/potion-base-8M"
12
+ _model = None
13
+ _failed = False
14
+
15
+
16
+ def _get_model():
17
+ global _model, _failed
18
+ if _model is None and not _failed:
19
+ try:
20
+ from model2vec import StaticModel
21
+
22
+ _model = StaticModel.from_pretrained(_MODEL_NAME)
23
+ except Exception:
24
+ _failed = True # sem lib/modelo/rede → degrada p/ MinHash
25
+ return _model
26
+
27
+
28
+ def available() -> bool:
29
+ return _get_model() is not None
30
+
31
+
32
+ def embed(text: str) -> list[float] | None:
33
+ """Vetor do texto (lista de floats) ou None se embedder indisponível."""
34
+ if not (text and text.strip()):
35
+ return None # texto vazio → vetor degenerado; não armazena
36
+ m = _get_model()
37
+ if m is None:
38
+ return None
39
+ vec = m.encode([text])[0]
40
+ return [round(float(x), 5) for x in vec]
41
+
42
+
43
+ def cosine(a: list[float] | None, b: list[float] | None) -> float:
44
+ if not a or not b or len(a) != len(b):
45
+ return 0.0
46
+ dot = sum(x * y for x, y in zip(a, b))
47
+ na = math.sqrt(sum(x * x for x in a))
48
+ nb = math.sqrt(sum(y * y for y in b))
49
+ return dot / (na * nb) if na and nb else 0.0
@@ -0,0 +1,184 @@
1
+ """Ledger de fontes ingeridas + dedup near-duplicate (sem embeddings/API).
2
+
3
+ Evita re-injetar a mesma pesquisa (ou quase-igual) num notebook. Dois sinais:
4
+ - hash exato do conteúdo normalizado (dup idêntica).
5
+ - MinHash sobre shingles de 3 tokens → estimativa de Jaccard (near-dup reescrito).
6
+
7
+ Dependency-free e determinístico (coeficientes derivados do índice, não aleatórios).
8
+ Persistido em ~/.notebooklm/ledger.json. NÃO é dedup semântico real (isso exigiria
9
+ embeddings) — é near-dup lexical, honesto sobre o que detecta.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ _LEDGER = Path.home() / ".notebooklm" / "ledger.json"
21
+ _WORD = re.compile(r"[a-z0-9áàâãéêíóôõúüç]+", re.IGNORECASE)
22
+ _NUM_PERM = 64 # tamanho da assinatura MinHash
23
+ _PRIME = (1 << 61) - 1
24
+ _SHINGLE = 3 # n-gram de tokens
25
+
26
+
27
+ def _norm_tokens(text: str) -> list[str]:
28
+ return _WORD.findall((text or "").lower())
29
+
30
+
31
+ def _content_hash(text: str) -> str:
32
+ return hashlib.sha256(" ".join(_norm_tokens(text)).encode("utf-8")).hexdigest()
33
+
34
+
35
+ def _query_key(query: str) -> str:
36
+ """Chave de query normalizada (case/espaço), preservando a ORDEM dos tokens.
37
+
38
+ Só casa re-runs da MESMA query (ex.: 'Fusão 2026' == 'fusao 2026'). NÃO funde
39
+ reordenações nem queries diferentes que reusam as mesmas palavras — evita
40
+ falso-skip (pular pesquisa nova) antes do Pro call. Precisão > recall aqui."""
41
+ return " ".join(_norm_tokens(query))
42
+
43
+
44
+ def _shingles(tokens: list[str]) -> set[int]:
45
+ if len(tokens) < _SHINGLE:
46
+ joined = " ".join(tokens)
47
+ return {int(hashlib.md5(joined.encode()).hexdigest(), 16)} if joined else set()
48
+ out = set()
49
+ for i in range(len(tokens) - _SHINGLE + 1):
50
+ s = " ".join(tokens[i : i + _SHINGLE])
51
+ out.add(int(hashlib.md5(s.encode()).hexdigest(), 16))
52
+ return out
53
+
54
+
55
+ def _minhash(text: str) -> list[int]:
56
+ """Assinatura MinHash determinística (coeficientes por índice)."""
57
+ sh = _shingles(_norm_tokens(text))
58
+ if not sh:
59
+ return [0] * _NUM_PERM
60
+ sig = []
61
+ for i in range(_NUM_PERM):
62
+ a = 2 * i + 1
63
+ b = i * i + 1
64
+ sig.append(min(((a * (x % _PRIME) + b) % _PRIME) for x in sh))
65
+ return sig
66
+
67
+
68
+ def _similarity(sig_a: list[int], sig_b: list[int]) -> float:
69
+ if not sig_a or not sig_b or len(sig_a) != len(sig_b):
70
+ return 0.0
71
+ return sum(1 for x, y in zip(sig_a, sig_b) if x == y) / len(sig_a)
72
+
73
+
74
+ @dataclass
75
+ class DupCheck:
76
+ is_dup: bool
77
+ similarity: float
78
+ matched_source_id: str | None
79
+ reason: str
80
+
81
+
82
+ def _load(path: Path) -> dict:
83
+ try:
84
+ return json.loads(path.read_text(encoding="utf-8"))
85
+ except Exception:
86
+ return {}
87
+
88
+
89
+ def _save(path: Path, data: dict) -> None:
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
92
+
93
+
94
+ def check_duplicate(
95
+ notebook_id: str,
96
+ content: str,
97
+ *,
98
+ threshold: float = 0.8,
99
+ semantic: bool = False,
100
+ semantic_threshold: float = 0.85,
101
+ path: Path = _LEDGER,
102
+ ) -> DupCheck:
103
+ """Verifica se `content` já foi ingerido (idêntico, near-verbatim, ou semântico).
104
+
105
+ Camadas: hash exato → MinHash (near-verbatim) → embeddings (semântico, se
106
+ `semantic=True` e o embedder local estiver disponível; senão ignora gracioso).
107
+ """
108
+ entries = _load(path).get(notebook_id, [])
109
+ chash = _content_hash(content)
110
+ sig = _minhash(content)
111
+
112
+ # Embedding do conteúdo (só se pedido e disponível) — caminho semântico.
113
+ cvec = None
114
+ if semantic:
115
+ try:
116
+ from .embed import embed
117
+
118
+ cvec = embed(content)
119
+ except Exception:
120
+ cvec = None
121
+
122
+ best = 0.0
123
+ best_id = None
124
+ best_sem = 0.0
125
+ best_sem_id = None
126
+ for e in entries:
127
+ if e.get("hash") == chash:
128
+ return DupCheck(True, 1.0, e.get("source_id"), "hash idêntico")
129
+ sim = _similarity(sig, e.get("minhash", []))
130
+ if sim > best:
131
+ best, best_id = sim, e.get("source_id")
132
+ if cvec is not None and e.get("embedding"):
133
+ from .embed import cosine
134
+
135
+ csim = cosine(cvec, e["embedding"])
136
+ if csim > best_sem:
137
+ best_sem, best_sem_id = csim, e.get("source_id")
138
+
139
+ if best_sem >= semantic_threshold:
140
+ return DupCheck(True, round(best_sem, 3), best_sem_id, f"semântico (cos={best_sem:.2f})")
141
+ if best >= threshold:
142
+ return DupCheck(True, round(best, 3), best_id, f"near-dup (sim={best:.2f})")
143
+ return DupCheck(False, round(max(best, best_sem), 3), None, "novo")
144
+
145
+
146
+ def query_seen(notebook_id: str, query: str, *, path: Path = _LEDGER) -> str | None:
147
+ """Retorna o source_id se a MESMA query já foi ingerida nesse notebook, senão None.
148
+
149
+ Pula re-pesquisa do mesmo tema ANTES de gastar a chamada Pro. Só casa queries
150
+ com a mesma chave (tokens únicos ordenados); query nova/diferente → None."""
151
+ qk = _query_key(query)
152
+ if not qk:
153
+ return None
154
+ for e in _load(path).get(notebook_id, []):
155
+ if e.get("query_key") == qk:
156
+ return e.get("source_id") or "(sem id)"
157
+ return None
158
+
159
+
160
+ def record(
161
+ notebook_id: str, content: str, title: str, source_id: str | None, *, query: str | None = None, path: Path = _LEDGER
162
+ ) -> None:
163
+ """Registra uma fonte ingerida (hash + minhash + query_key + metadados)."""
164
+ # Embedding semântico (gracioso: None se o embedder local não estiver disponível).
165
+ emb = None
166
+ try:
167
+ from .embed import embed as _embed
168
+
169
+ emb = _embed(content)
170
+ except Exception:
171
+ emb = None
172
+
173
+ data = _load(path)
174
+ entry = {
175
+ "hash": _content_hash(content),
176
+ "minhash": _minhash(content),
177
+ "query_key": _query_key(query) if query else "",
178
+ "title": title,
179
+ "source_id": source_id,
180
+ }
181
+ if emb is not None:
182
+ entry["embedding"] = emb
183
+ data.setdefault(notebook_id, []).append(entry)
184
+ _save(path, data)