wuptracker 0.1.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.
wuptracker/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """wuptracker — captura screenshots de uma sessão de trabalho e gera writeups com IA."""
2
+
3
+ __version__ = "0.1.0"
wuptracker/capture.py ADDED
@@ -0,0 +1,342 @@
1
+ """Captura de tela em background durante uma sessão de trabalho.
2
+
3
+ Uso:
4
+ python capture.py "Skynet"
5
+ python capture.py "Refatorar API" --profile generico
6
+ python capture.py # pede o nome interativamente
7
+
8
+ Perfis (--profile): "thm" (pentest/CTF) | "generico" (qualquer trabalho).
9
+ Padrão em config.DEFAULT_PROFILE.
10
+
11
+ Durante a sessão:
12
+ F9 -> captura a tela, analisa com o Claude e salva
13
+ F10 / Ctrl+C -> encerra a sessão
14
+ (Wayland) pkill -USR1/-USR2 -f capture.py
15
+ """
16
+
17
+ import datetime as dt
18
+ import json
19
+ import os
20
+ import signal
21
+ import sys
22
+ import threading
23
+ import time
24
+ from concurrent.futures import ThreadPoolExecutor
25
+
26
+ from pynput import keyboard
27
+
28
+ from . import common
29
+ from . import config
30
+ from . import llm
31
+ from . import profiles
32
+
33
+
34
+ class Session:
35
+ def __init__(self, room, profile="thm"):
36
+ self.room = room
37
+ self.profile = profiles.resolve(profile)
38
+ self.analyze_prompt = profiles.get(self.profile)["analyze_prompt"]
39
+ self.start = dt.datetime.now()
40
+ stamp = self.start.strftime("%Y-%m-%d_%H-%M")
41
+ self.name = f"{stamp}_{common.slugify(room)}"
42
+ self.dir = os.path.join(config.SESSIONS_DIR, self.name)
43
+ self.captures_dir = os.path.join(self.dir, "captures")
44
+ os.makedirs(self.captures_dir, exist_ok=True)
45
+ self.captures = [] # entradas do session.json
46
+ self.lock = threading.Lock()
47
+ self.count = 0 # nº de capturas que começaram a processar
48
+ self.submitted = 0 # nº de F9/sinais disparados
49
+ self.completed = 0 # nº de capturas que terminaram (ok ou erro)
50
+ self._write_meta()
51
+ self._write_session()
52
+
53
+ @property
54
+ def pending(self):
55
+ return self.submitted - self.completed
56
+
57
+ def submit(self, pool):
58
+ with self.lock:
59
+ self.submitted += 1
60
+ pool.submit(self._run)
61
+
62
+ def _run(self):
63
+ try:
64
+ self.do_capture()
65
+ except Exception as e:
66
+ print(common.c_red(f"[!] erro na captura: {e}"))
67
+ finally:
68
+ with self.lock:
69
+ self.completed += 1
70
+
71
+ # ---------- persistência ----------
72
+ def _write_meta(self, ended=None):
73
+ meta = {
74
+ "room": self.room,
75
+ "profile": self.profile,
76
+ "started_at": self.start.isoformat(timespec="seconds"),
77
+ "system": common.system_name(),
78
+ }
79
+ if ended:
80
+ meta["ended_at"] = ended.isoformat(timespec="seconds")
81
+ meta["duration"] = common.fmt_elapsed(
82
+ (ended - self.start).total_seconds())
83
+ with open(os.path.join(self.dir, "meta.json"), "w", encoding="utf-8") as f:
84
+ json.dump(meta, f, indent=2, ensure_ascii=False)
85
+
86
+ def _write_session(self):
87
+ ordered = sorted(self.captures, key=lambda c: c["filename"])
88
+ with open(os.path.join(self.dir, "session.json"), "w", encoding="utf-8") as f:
89
+ json.dump({"captures": ordered}, f, indent=2, ensure_ascii=False)
90
+
91
+ def add_capture(self, entry):
92
+ with self.lock:
93
+ self.captures.append(entry)
94
+ self._write_session()
95
+
96
+ # ---------- captura ----------
97
+ def elapsed_label(self):
98
+ secs = (dt.datetime.now() - self.start).total_seconds()
99
+ return common.fmt_elapsed(secs)
100
+
101
+ def do_capture(self):
102
+ label = self.elapsed_label()
103
+ fname = label.replace(":", "-")
104
+ png_path = os.path.join(self.captures_dir, f"{fname}.png")
105
+ json_path = os.path.join(self.captures_dir, f"{fname}.json")
106
+
107
+ with self.lock:
108
+ self.count += 1
109
+ n = self.count
110
+ queued = self.pending - 1
111
+ q = f" ({queued} na fila)" if queued > 0 else ""
112
+ print(common.c_yellow(f"[{label}] capturando… (#{n}){q}"))
113
+
114
+ try:
115
+ common.grab_screen(png_path)
116
+ except Exception as e:
117
+ print(common.c_red(f"[{label}] falha na captura de tela: {e}"))
118
+ return
119
+
120
+ common.beep()
121
+
122
+ analysis = self._analyze(png_path, label)
123
+
124
+ # imagem a exibir no writeup: recorte quando o modelo indicar uma região
125
+ image = f"{fname}.png"
126
+ crop_note = "sem recorte"
127
+ if getattr(config, "CROP_IMAGES", True) and isinstance(analysis.get("crop"), dict):
128
+ crop_path = os.path.join(self.captures_dir, f"{fname}_crop.png")
129
+ try:
130
+ if common.crop_image(png_path, crop_path, analysis["crop"]):
131
+ image = f"{fname}_crop.png"
132
+ ow, oh = common.image_size(png_path)
133
+ cw, ch = common.image_size(crop_path)
134
+ pct = round(100 * (cw * ch) / (ow * oh)) if ow and oh else 0
135
+ crop_note = f"recorte {cw}×{ch} ({pct}% de {ow}×{oh})"
136
+ except Exception as e:
137
+ print(common.c_yellow(f"[{label}] recorte falhou: {e}"))
138
+ analysis["image"] = image
139
+
140
+ with open(json_path, "w", encoding="utf-8") as f:
141
+ json.dump(analysis, f, indent=2, ensure_ascii=False)
142
+
143
+ title = analysis.get("title") or "[análise pendente]"
144
+ # "phase" (thm) ou "category" (generico) — guarda o que houver
145
+ bucket = analysis.get("phase") or analysis.get("category") or "misc"
146
+ self.add_capture({
147
+ "timestamp": label,
148
+ "filename": fname,
149
+ "title": title,
150
+ "phase": bucket,
151
+ "image": image,
152
+ "is_key_moment": bool(analysis.get("is_key_moment")),
153
+ })
154
+
155
+ star = " ★" if analysis.get("is_key_moment") else ""
156
+ print(common.c_green(f"[{label}] ✓ {title}{star}") +
157
+ common.c_dim(f" · {crop_note}"))
158
+ common.notify("Writeup Capture", f"[{label}] {title}")
159
+
160
+ def _analyze(self, png_path, label):
161
+ try:
162
+ text = llm.analyze_image(png_path, self.analyze_prompt)
163
+ return _parse_json(text)
164
+ except Exception as e:
165
+ print(common.c_red(f"[{label}] análise falhou: {e}"))
166
+ return {
167
+ "error": str(e),
168
+ "description": "[análise pendente]",
169
+ "phase": "misc",
170
+ "title": f"Captura {label} (análise pendente)",
171
+ "commands": [], "findings": [], "tool": None,
172
+ "is_key_moment": False, "key_moment_reason": None,
173
+ }
174
+
175
+ def finish(self):
176
+ end = dt.datetime.now()
177
+ self._write_meta(ended=end)
178
+ try:
179
+ os.remove(os.path.join(self.dir, "capture.pid"))
180
+ except OSError:
181
+ pass
182
+ dur = common.fmt_elapsed((end - self.start).total_seconds())
183
+ done = len(self.captures)
184
+ print()
185
+ print(common.c_cyan(f"Sessão encerrada. {done} capturas em {dur}"))
186
+ if done != self.count:
187
+ print(common.c_yellow(
188
+ f"({self.count - done} captura(s) não finalizaram a análise)"))
189
+ print(common.c_dim(f"Gerar o writeup: wuptracker generate {self.name}"))
190
+
191
+
192
+ def _parse_json(text):
193
+ try:
194
+ return json.loads(text)
195
+ except json.JSONDecodeError:
196
+ start = text.find("{")
197
+ end = text.rfind("}")
198
+ if start != -1 and end != -1:
199
+ try:
200
+ return json.loads(text[start:end + 1])
201
+ except json.JSONDecodeError:
202
+ pass
203
+ return {
204
+ "error": "resposta não-JSON",
205
+ "raw": text,
206
+ "description": "[análise pendente]",
207
+ "phase": "misc", "title": "[análise pendente]",
208
+ "commands": [], "findings": [], "tool": None,
209
+ "is_key_moment": False, "key_moment_reason": None,
210
+ }
211
+
212
+
213
+ def capture(room=None, profile=None):
214
+ """Inicia o modo de captura e bloqueia até o encerramento da sessão."""
215
+ try:
216
+ sys.stdout.reconfigure(line_buffering=True)
217
+ except Exception:
218
+ pass
219
+ backend = common.check_capture_backend()
220
+ if not backend:
221
+ sys.exit("[!] Nenhum backend de captura disponível. Instale 'mss' "
222
+ "(pip install -r requirements.txt) ou 'scrot'.")
223
+ # valida o backend do LLM cedo
224
+ llm.preflight()
225
+
226
+ profile = profiles.resolve_domain(
227
+ profile or getattr(config, "DEFAULT_PROFILE", "thm"))
228
+
229
+ room = room or input("Nome da sessão: ").strip()
230
+ if not room:
231
+ sys.exit("[!] Nome da sessão vazio.")
232
+
233
+ session = Session(room, profile)
234
+ pool = ThreadPoolExecutor(max_workers=4)
235
+ stop = threading.Event()
236
+
237
+ # SIGUSR1 = capturar | SIGUSR2 = encerrar (para atalhos globais no Wayland).
238
+ signal.signal(signal.SIGUSR1, lambda *_: session.submit(pool))
239
+ signal.signal(signal.SIGUSR2, lambda *_: stop.set())
240
+
241
+ pid = os.getpid()
242
+ prog = os.path.basename(sys.argv[0]) or "wuptracker.py"
243
+ pidfile = os.path.join(session.dir, "capture.pid")
244
+ try:
245
+ with open(pidfile, "w") as f:
246
+ f.write(str(pid))
247
+ except OSError:
248
+ pass
249
+
250
+ print(common.c_green(f"[✓] Sessão iniciada: {session.name}"))
251
+ print(f" Perfil: {session.profile} | Captura: {backend} | PID: {pid}")
252
+
253
+ wayland = bool(os.environ.get("WAYLAND_DISPLAY")) or \
254
+ os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
255
+
256
+ listener = None
257
+ if wayland:
258
+ print(common.c_yellow(
259
+ " Wayland: teclas globais (F9) podem não funcionar. Use sinais —\n"
260
+ f" capturar: kill -USR1 {pid} (ou pkill -USR1 -f {prog})\n"
261
+ f" encerrar: kill -USR2 {pid} (ou pkill -USR2 -f {prog})\n"
262
+ " Dica: crie um atalho global (KDE/GNOME) apontando para esse comando.\n"
263
+ " (ou mantenha este terminal focado para o F9 funcionar)"))
264
+
265
+ try:
266
+ capture_key = getattr(keyboard.Key, config.CAPTURE_KEY)
267
+ exit_key = getattr(keyboard.Key, config.EXIT_KEY)
268
+
269
+ def on_press(key):
270
+ if key == capture_key:
271
+ session.submit(pool)
272
+ elif key == exit_key:
273
+ stop.set()
274
+ return False
275
+
276
+ listener = keyboard.Listener(on_press=on_press)
277
+ listener.start()
278
+ print(f" F9 = capturar | F10 ou Ctrl+C = encerrar")
279
+ except Exception as e:
280
+ print(common.c_yellow(f" Listener de teclado indisponível ({e}). "
281
+ "Use os sinais acima ou Ctrl+C."))
282
+
283
+ last_note = 0.0
284
+ try:
285
+ while not stop.is_set():
286
+ time.sleep(0.2)
287
+ now = time.time()
288
+ if session.pending and now - last_note > 8:
289
+ print(common.c_dim(
290
+ f" … {session.pending} análise(s) em processamento"))
291
+ last_note = now
292
+ except KeyboardInterrupt:
293
+ print()
294
+ finally:
295
+ if listener is not None:
296
+ listener.stop()
297
+ _drain(session, pool)
298
+ session.finish()
299
+
300
+
301
+ def _drain(session, pool):
302
+ """Espera as análises pendentes terminarem, com progresso e proteção contra
303
+ um segundo Ctrl+C que deixaria arquivos pela metade."""
304
+ if session.pending <= 0:
305
+ pool.shutdown(wait=True)
306
+ return
307
+
308
+ print(common.c_yellow(
309
+ f"⏳ {session.pending} análise(s) ainda processando — "
310
+ "NÃO feche o terminal."))
311
+ forced = False
312
+ try:
313
+ while session.completed < session.submitted:
314
+ done, total = session.completed, session.submitted
315
+ print(f"\r {done}/{total} concluídas… ", end="", flush=True)
316
+ time.sleep(0.3)
317
+ print(f"\r {session.submitted}/{session.submitted} concluídas. ")
318
+ except KeyboardInterrupt:
319
+ forced = True
320
+ left = session.submitted - session.completed
321
+ print(common.c_red(
322
+ f"\n[!] Interrompido à força com {left} análise(s) incompleta(s). "
323
+ "Os screenshots (.png) foram salvos; as análises faltantes ficarão "
324
+ "sem .json. O generate.py ainda funciona com o que houver."))
325
+ pool.shutdown(wait=not forced, cancel_futures=forced)
326
+
327
+
328
+ def main(argv=None):
329
+ args = list(sys.argv[1:] if argv is None else argv)
330
+ profile = None
331
+ if "--profile" in args:
332
+ i = args.index("--profile")
333
+ try:
334
+ profile = args[i + 1]
335
+ except IndexError:
336
+ sys.exit("[!] --profile requer um valor (thm | generico).")
337
+ del args[i:i + 2]
338
+ capture(args[0] if args else None, profile)
339
+
340
+
341
+ if __name__ == "__main__":
342
+ main()
wuptracker/cli.py ADDED
@@ -0,0 +1,187 @@
1
+ """wuptracker — captura e gera writeups de sessões de trabalho / pentest.
2
+
3
+ wuptracker capture ["Nome da sessão"] [--profile thm|generico]
4
+ wuptracker show [--writeups]
5
+ wuptracker generate [SESSÃO] [--style ...] [--profile ...] [--export DIR] [--open]
6
+ wuptracker styles
7
+ wuptracker config [show | set <k> <v> | unset <k> | get <k> | api-key <chave>]
8
+ wuptracker config helper # assistente interativo (primeira vez? comece aqui)
9
+
10
+ SESSÃO pode ser: o número mostrado no `show`, o nome da pasta, um caminho, ou
11
+ vazio (usa a mais recente). `--style` aceita vários: --style tecnico,linkedin
12
+ """
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+
18
+ from . import common
19
+ from . import config
20
+ from . import profiles
21
+
22
+
23
+ def _fmt_dur(d):
24
+ return d or "em andamento"
25
+
26
+
27
+ def cmd_show(a):
28
+ sessions = common.list_sessions()
29
+ if not sessions:
30
+ print("Nenhuma sessão em " + config.SESSIONS_DIR + "/")
31
+ return
32
+ w = max(len(s["room"]) for s in sessions)
33
+ print(common.c_cyan(
34
+ f" # {'SESSÃO'.ljust(w)} DATA/HORA CAPS PERFIL STATUS"))
35
+ for i, s in enumerate(sessions, 1):
36
+ when = s["started_at"].replace("T", " ")[:16]
37
+ caps = str(s["captures"])
38
+ if s["pending"]:
39
+ caps += common.c_yellow(f"(+{s['pending']}?)")
40
+ status = []
41
+ if s["ongoing"]:
42
+ status.append(common.c_yellow("ativa"))
43
+ elif s.get("crashed"):
44
+ status.append(common.c_red("interrompida"))
45
+ if s["has_writeup"]:
46
+ status.append(common.c_green("writeup"))
47
+ line = (f" {i:>2} {s['room'].ljust(w)} {when} "
48
+ f"{caps:>4} {s['profile'].ljust(9)} {' '.join(status)}")
49
+ print(line)
50
+ print(common.c_dim(f"\n {len(sessions)} sessão(ões). "
51
+ f"wuptracker generate <#> para gerar o writeup."))
52
+
53
+
54
+ def cmd_styles(a):
55
+ print(common.c_cyan("Estilos de writeup (--style):"))
56
+ desc = {
57
+ "tecnico": "denso e preciso, seções fixas, horários (padrão)",
58
+ "corrido": "narrativa fluida em texto corrido",
59
+ "resumo": "resumo executivo curto (~250 palavras)",
60
+ "blog": "artigo técnico didático (dev.to / Medium)",
61
+ "linkedin": "post pronto para o LinkedIn (texto puro, hashtags)",
62
+ }
63
+ for k, v in desc.items():
64
+ mark = common.c_dim(" [com imagens]") if k in profiles.STYLES_WITH_IMAGES else ""
65
+ print(f" {common.c_green(k.ljust(9))} {v}{mark}")
66
+ print(common.c_dim("\n Perfis (--profile): thm | generico"))
67
+
68
+
69
+ def _resolve_session(token):
70
+ sessions = common.list_sessions()
71
+ if token is None:
72
+ if not sessions:
73
+ sys.exit("[!] Nenhuma sessão. Rode 'wuptracker capture' primeiro.")
74
+ return sessions[0]["dir"]
75
+ if token.isdigit():
76
+ idx = int(token) - 1
77
+ if not (0 <= idx < len(sessions)):
78
+ sys.exit(f"[!] Sessão #{token} não existe. Veja 'wuptracker show'.")
79
+ return sessions[idx]["dir"]
80
+ if os.path.isdir(token):
81
+ return token
82
+ cand = os.path.join(config.SESSIONS_DIR, token)
83
+ if os.path.isdir(cand):
84
+ return cand
85
+ sys.exit(f"[!] Sessão não encontrada: {token}")
86
+
87
+
88
+ def cmd_capture(a):
89
+ from . import capture as capture_mod
90
+
91
+ capture_mod.capture(a.name, a.profile)
92
+
93
+
94
+ def cmd_generate(a):
95
+ from . import generate as generate_mod
96
+
97
+ session_dir = _resolve_session(a.session)
98
+ generate_mod.generate(session_dir, a.style, a.profile, a.export, a.open)
99
+
100
+
101
+ def cmd_config(a):
102
+ from . import configtool
103
+
104
+ act, rest = a.action or "show", a.rest
105
+ if act == "show":
106
+ configtool.show()
107
+ elif act == "set":
108
+ if len(rest) < 2:
109
+ sys.exit("uso: wuptracker config set <chave> <valor>")
110
+ configtool.set_(rest[0], " ".join(rest[1:]))
111
+ elif act == "get":
112
+ if not rest:
113
+ sys.exit("uso: wuptracker config get <chave>")
114
+ k = configtool._resolve_key(rest[0])
115
+ print(configtool._fmt(getattr(config, k, None)))
116
+ elif act == "unset":
117
+ if not rest:
118
+ sys.exit("uso: wuptracker config unset <chave>")
119
+ configtool.unset_(rest[0])
120
+ elif act in ("api-key", "apikey"):
121
+ if not rest:
122
+ sys.exit("uso: wuptracker config api-key <chave>")
123
+ configtool.set_api_key(rest[0])
124
+ elif act == "backend":
125
+ if not rest:
126
+ sys.exit("uso: wuptracker config backend "
127
+ "<claude_cli|anthropic|ollama|openai|gemini>")
128
+ configtool.set_("backend", rest[0])
129
+ elif act in ("helper", "wizard", "setup"):
130
+ configtool.wizard()
131
+ else:
132
+ sys.exit(f"[!] ação de config desconhecida: {act}")
133
+
134
+
135
+ def build_parser():
136
+ p = argparse.ArgumentParser(
137
+ prog="wuptracker", description=__doc__,
138
+ formatter_class=argparse.RawDescriptionHelpFormatter)
139
+ sub = p.add_subparsers(dest="cmd", required=True)
140
+
141
+ c = sub.add_parser("capture", help="inicia o modo de captura")
142
+ c.add_argument("name", nargs="?", help="nome da sessão")
143
+ c.add_argument("--profile", help="thm | generico (padrão: config.DEFAULT_PROFILE)")
144
+ c.set_defaults(func=cmd_capture)
145
+
146
+ s = sub.add_parser("show", help="lista as sessões")
147
+ s.add_argument("--writeups", action="store_true",
148
+ help="(reservado) só sessões já com writeup")
149
+ s.set_defaults(func=cmd_show)
150
+
151
+ g = sub.add_parser("generate", help="gera o writeup de uma sessão")
152
+ g.add_argument("session", nargs="?",
153
+ help="número do 'show', nome, caminho, ou vazio p/ a mais recente")
154
+ g.add_argument("--style", help="tecnico|corrido|resumo|blog|linkedin (vírgula p/ vários)")
155
+ g.add_argument("--profile", help="força thm | generico")
156
+ g.add_argument("--export", metavar="DIR", help="copia o(s) writeup(s) e assets para DIR")
157
+ g.add_argument("--open", action="store_true", help="abre o resultado no editor")
158
+ g.set_defaults(func=cmd_generate)
159
+
160
+ sub.add_parser("styles", help="lista estilos e perfis").set_defaults(func=cmd_styles)
161
+
162
+ cfg = sub.add_parser("config", help="ver/alterar configurações e integrações")
163
+ cfg.add_argument("action", nargs="?", default="show",
164
+ choices=["show", "set", "get", "unset", "api-key", "backend",
165
+ "helper"])
166
+ cfg.add_argument("rest", nargs="*", metavar="ARG")
167
+ cfg.set_defaults(func=cmd_config)
168
+
169
+ setup = sub.add_parser(
170
+ "setup", help="assistente interativo de configuração (atalho p/ config helper)")
171
+ setup.set_defaults(func=cmd_setup)
172
+ return p
173
+
174
+
175
+ def cmd_setup(a):
176
+ from . import configtool
177
+
178
+ configtool.wizard()
179
+
180
+
181
+ def main():
182
+ args = build_parser().parse_args()
183
+ args.func(args)
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()