captchakraken 2.4.0 → 2.6.1
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.
- package/LICENSE +47 -7
- package/README.md +60 -13
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +217 -12
- package/dist/solver.js +1332 -172
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +158 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +12 -7
- package/python/LICENSE +47 -7
- package/python/README.md +58 -13
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +24 -6
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +212 -2
- package/python/src/captchakraken/config.py +39 -5
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1559 -105
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +253 -59
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -10,7 +10,15 @@ Flow:
|
|
|
10
10
|
3. Otherwise (click / drag / other still-image puzzle) → route the image to
|
|
11
11
|
the full-puzzle pixel/action path (planner.get_pixel_actions), which the
|
|
12
12
|
LoRA is trained on. Only raise UnsupportedCaptchaError if the model returns
|
|
13
|
-
nothing usable.
|
|
13
|
+
nothing usable.
|
|
14
|
+
|
|
15
|
+
ANIMATED challenges take a separate entry point, `solve_keyframes`. They used to be
|
|
16
|
+
detected and skipped: the settle monitor called them "never settles" and the solve
|
|
17
|
+
was abandoned. Now the driver records the widget, `keyframes.py` slices the
|
|
18
|
+
recording into the few stills that carry the answer, and those go to the model as
|
|
19
|
+
one multi-image request. The answer comes back with a `frame`, and the driver waits
|
|
20
|
+
for the page to look like that frame before it clicks — because on these puzzles
|
|
21
|
+
the coordinates are only correct while the target is actually on screen.
|
|
14
22
|
|
|
15
23
|
v1 had a SAM3-backed tool-using planner with detect/segment/drag-refine; it
|
|
16
24
|
lives on the `v1-old-architecture` branch.
|
|
@@ -23,7 +31,7 @@ import sys
|
|
|
23
31
|
import tempfile
|
|
24
32
|
from datetime import datetime
|
|
25
33
|
from pathlib import Path
|
|
26
|
-
from typing import Any, List, Optional, Tuple, Union
|
|
34
|
+
from typing import Any, List, Optional, Sequence, Tuple, Union
|
|
27
35
|
|
|
28
36
|
from PIL import Image
|
|
29
37
|
|
|
@@ -32,6 +40,7 @@ from .action_types import (
|
|
|
32
40
|
ClickAction,
|
|
33
41
|
DoneAction,
|
|
34
42
|
DragAction,
|
|
43
|
+
TypeAction,
|
|
35
44
|
WaitAction,
|
|
36
45
|
)
|
|
37
46
|
from .image_processor import ImageProcessor
|
|
@@ -140,6 +149,7 @@ class CaptchaSolver:
|
|
|
140
149
|
instruction: str = "",
|
|
141
150
|
puzzle_source: str = "unknown",
|
|
142
151
|
retry_mode: Optional[str] = None,
|
|
152
|
+
text_mode: bool = False,
|
|
143
153
|
) -> Union[CaptchaAction, List[CaptchaAction]]:
|
|
144
154
|
media_path = str(Path(media_path).resolve())
|
|
145
155
|
if not os.path.exists(media_path):
|
|
@@ -150,6 +160,27 @@ class CaptchaSolver:
|
|
|
150
160
|
assert self._image_size is not None
|
|
151
161
|
img_w, img_h = self._image_size
|
|
152
162
|
|
|
163
|
+
if text_mode:
|
|
164
|
+
# The driver saw a text box in the widget, so the answer is a string
|
|
165
|
+
# and there is nothing on the picture to find. Grid and checkbox
|
|
166
|
+
# detection are skipped rather than merely ignored: BotDetect's
|
|
167
|
+
# boxed, evenly-spaced glyphs are exactly the lattice find_grid
|
|
168
|
+
# looks for, and a false grid here would answer a typing puzzle
|
|
169
|
+
# with a list of cell numbers.
|
|
170
|
+
try:
|
|
171
|
+
actions = self._solve_pixel(cv_image_path, text_mode=True)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
# The served model predates the distorted-text family, so there
|
|
174
|
+
# is no prompt to send it. Surfaced as UNSUPPORTED rather than
|
|
175
|
+
# crashing: it is the same class of outcome as "this LoRA only
|
|
176
|
+
# does grids", the driver already knows how to report it, and
|
|
177
|
+
# the message names the real fix (a generation-2 model) instead
|
|
178
|
+
# of a stack trace in prompts.py.
|
|
179
|
+
raise UnsupportedCaptchaError(str(exc)) from exc
|
|
180
|
+
if actions:
|
|
181
|
+
return actions
|
|
182
|
+
raise UnsupportedCaptchaError("Could not read the text captcha")
|
|
183
|
+
|
|
153
184
|
with timed("solver.find_grid"):
|
|
154
185
|
grid_boxes = find_grid(cv_image_path)
|
|
155
186
|
if grid_boxes and self._is_real_grid(cv_image_path, grid_boxes):
|
|
@@ -178,9 +209,8 @@ class CaptchaSolver:
|
|
|
178
209
|
|
|
179
210
|
# Not a grid or checkbox → a click/drag/pixel puzzle. The full-puzzle
|
|
180
211
|
# LoRA is trained on all of these, so route the image to the pixel
|
|
181
|
-
# action path rather than bailing. (
|
|
182
|
-
#
|
|
183
|
-
# is a still-image puzzle worth attempting.)
|
|
212
|
+
# action path rather than bailing. (Animated challenges come in through
|
|
213
|
+
# `solve_keyframes` instead, so anything here is a still worth attempting.)
|
|
184
214
|
actions = self._solve_pixel(cv_image_path)
|
|
185
215
|
if actions:
|
|
186
216
|
return actions
|
|
@@ -190,26 +220,87 @@ class CaptchaSolver:
|
|
|
190
220
|
# instead of clicking nothing.
|
|
191
221
|
raise UnsupportedCaptchaError("Cannot solve this kind of captcha")
|
|
192
222
|
|
|
223
|
+
def solve_keyframes(
|
|
224
|
+
self, keyframe_paths: Sequence[str]
|
|
225
|
+
) -> List[Union[ClickAction, DragAction]]:
|
|
226
|
+
"""Solve an ANIMATED challenge from the keyframes of a recording.
|
|
227
|
+
|
|
228
|
+
`keyframe_paths` must be in model order (frame 1 first) — the numbers the
|
|
229
|
+
answer refers to are positional, so a reordered list silently remaps the
|
|
230
|
+
answer onto the wrong picture. `keyframes.materialize_keyframes` and
|
|
231
|
+
`read_keyframe_paths` both return them correctly ordered; do not sort them
|
|
232
|
+
yourself with a plain string sort past nine frames.
|
|
233
|
+
|
|
234
|
+
Grid detection is deliberately NOT attempted. An animated challenge is never
|
|
235
|
+
a tile grid (vendors do not animate those), and `find_grid` false-positives
|
|
236
|
+
on the header/footer bands of hCaptcha's click puzzles — which is what these
|
|
237
|
+
are. Every returned action carries `await_keyframe`, the still the driver
|
|
238
|
+
must see on screen before it acts.
|
|
239
|
+
"""
|
|
240
|
+
paths = [str(p) for p in keyframe_paths]
|
|
241
|
+
if not paths:
|
|
242
|
+
raise UnsupportedCaptchaError("no keyframes to solve from")
|
|
243
|
+
with Image.open(paths[0]) as im:
|
|
244
|
+
self._image_size = im.size
|
|
245
|
+
self.debug.save_image(paths[0], "00_keyframe_01.png")
|
|
246
|
+
self.debug.log(f"solving animated challenge from {len(paths)} keyframes")
|
|
247
|
+
|
|
248
|
+
actions = self._to_actions(
|
|
249
|
+
self.planner.get_keyframe_actions(paths), keyframe_paths=paths
|
|
250
|
+
)
|
|
251
|
+
if actions:
|
|
252
|
+
return actions
|
|
253
|
+
raise UnsupportedCaptchaError("Cannot solve this animated captcha")
|
|
254
|
+
|
|
193
255
|
def _solve_pixel(
|
|
194
|
-
self, image_path: str
|
|
256
|
+
self, image_path: str, text_mode: bool = False
|
|
195
257
|
) -> List[Union[ClickAction, DragAction]]:
|
|
196
258
|
"""Turn the model's normalized 0–1 click/drag actions into ClickAction /
|
|
197
259
|
DragAction bboxes. Each point becomes a small box centered on it (the TS
|
|
198
260
|
solver clicks the box center)."""
|
|
199
|
-
|
|
261
|
+
return self._to_actions(self.planner.get_pixel_actions(image_path, text_mode=text_mode))
|
|
262
|
+
|
|
263
|
+
def _to_actions(
|
|
264
|
+
self,
|
|
265
|
+
raw_actions: List[dict],
|
|
266
|
+
keyframe_paths: Optional[Sequence[str]] = None,
|
|
267
|
+
) -> List[Union[ClickAction, DragAction]]:
|
|
268
|
+
"""Shared conversion from the planner's normalized 0–1 actions to typed
|
|
269
|
+
Click/Drag actions with small boxes around each point.
|
|
270
|
+
|
|
271
|
+
When `keyframe_paths` is given, the chosen keyframe is attached so the
|
|
272
|
+
driver knows which page state to wait for. A model answer with no usable
|
|
273
|
+
frame is still converted — it just carries no keyframe, and the driver
|
|
274
|
+
treats that as "act on the current frame". That is the honest fallback:
|
|
275
|
+
refusing the action outright would turn a good coordinate into a failed
|
|
276
|
+
solve over a missing integer.
|
|
277
|
+
"""
|
|
200
278
|
R = _PIXEL_BOX_HALF
|
|
201
279
|
clamp = lambda v: min(max(v, 0.0), 1.0)
|
|
202
280
|
|
|
203
281
|
def box(cx: float, cy: float) -> List[float]:
|
|
204
282
|
return [clamp(cx - R), clamp(cy - R), clamp(cx + R), clamp(cy + R)]
|
|
205
283
|
|
|
284
|
+
def wait_for(a: dict) -> dict:
|
|
285
|
+
if not keyframe_paths:
|
|
286
|
+
return {}
|
|
287
|
+
frame = a.get("frame")
|
|
288
|
+
if not isinstance(frame, int) or not 1 <= frame <= len(keyframe_paths):
|
|
289
|
+
self.debug.log(
|
|
290
|
+
"animated answer named no usable keyframe; acting on the "
|
|
291
|
+
"current frame without waiting"
|
|
292
|
+
)
|
|
293
|
+
return {}
|
|
294
|
+
return {"frame": frame, "await_keyframe": str(keyframe_paths[frame - 1])}
|
|
295
|
+
|
|
206
296
|
out: List[Union[ClickAction, DragAction]] = []
|
|
207
297
|
for a in raw_actions:
|
|
208
298
|
if a.get("kind") == "click":
|
|
209
299
|
boxes = [box(x, y) for (x, y) in a.get("points", [])]
|
|
210
300
|
if boxes:
|
|
211
301
|
out.append(
|
|
212
|
-
ClickAction(action="click", target_bounding_boxes=boxes
|
|
302
|
+
ClickAction(action="click", target_bounding_boxes=boxes,
|
|
303
|
+
**wait_for(a))
|
|
213
304
|
)
|
|
214
305
|
elif a.get("kind") == "drag":
|
|
215
306
|
sx, sy = a["src"]
|
|
@@ -219,11 +310,33 @@ class CaptchaSolver:
|
|
|
219
310
|
action="drag",
|
|
220
311
|
source_bounding_box=box(sx, sy),
|
|
221
312
|
target_bounding_box=box(dx, dy),
|
|
313
|
+
**wait_for(a),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
elif a.get("kind") == "slide":
|
|
317
|
+
# A puzzle-piece slider, carried as a drag with NO source. That
|
|
318
|
+
# is not a lossy encoding of a normal drag — it is the whole
|
|
319
|
+
# shape of the answer: the thing you grab (the handle) is not
|
|
320
|
+
# the thing that has to arrive (the piece), and only the page
|
|
321
|
+
# knows where the handle is. The driver reads a null source as
|
|
322
|
+
# "find the slider and close the loop on the piece".
|
|
323
|
+
dx, dy = a["dst"]
|
|
324
|
+
out.append(
|
|
325
|
+
DragAction(
|
|
326
|
+
action="drag",
|
|
327
|
+
source_bounding_box=None,
|
|
328
|
+
target_bounding_box=box(dx, dy),
|
|
329
|
+
**wait_for(a),
|
|
222
330
|
)
|
|
223
331
|
)
|
|
332
|
+
elif a.get("kind") == "type":
|
|
333
|
+
out.append(TypeAction(action="type", text=a["text"]))
|
|
224
334
|
return out
|
|
225
335
|
|
|
226
|
-
# Back-compat alias.
|
|
336
|
+
# Back-compat alias. Kept pointing at `solve` (a single still), NOT at
|
|
337
|
+
# `solve_keyframes`: callers of this name pass one media path and expect one
|
|
338
|
+
# answer, and quietly reinterpreting that as "record and slice" would change
|
|
339
|
+
# what an old integration does. New code calls `solve_keyframes` explicitly.
|
|
227
340
|
def solveVideo(self, *args, **kwargs):
|
|
228
341
|
return self.solve(*args, **kwargs)
|
|
229
342
|
|
|
@@ -299,6 +412,42 @@ class CaptchaSolver:
|
|
|
299
412
|
f"_is_real_grid: per-cell stddev = {[round(s, 1) for s in stds]}"
|
|
300
413
|
)
|
|
301
414
|
|
|
415
|
+
# A SPRITE BOARD is not a tile grid, however grid-shaped it is.
|
|
416
|
+
#
|
|
417
|
+
# GeeTest's iconcrush and gobang are lattices of sprites drawn on
|
|
418
|
+
# ONE flat board, so find_grid finds them and every cell is
|
|
419
|
+
# colourful enough (mean stddev ~52) to clear every check below.
|
|
420
|
+
# iconcrush was routed to `_solve_grid` on every capture, which
|
|
421
|
+
# asks the model to "choose the cell numbers that match the
|
|
422
|
+
# description" — the wrong question for a board you answer by
|
|
423
|
+
# tapping two tiles to swap them. Nothing errors: the reply comes
|
|
424
|
+
# back as `[3, 6, 9]`, is re-encoded as clicks on those cell
|
|
425
|
+
# centres, and is graded against a two-click gold. The type scored
|
|
426
|
+
# 0.089 that way and read as the model's weakest.
|
|
427
|
+
#
|
|
428
|
+
# The discriminator is the BACKGROUND, not the content. A real
|
|
429
|
+
# captcha grid is independent photographs, so each tile's median
|
|
430
|
+
# colour is its own; a sprite board shares one board colour behind
|
|
431
|
+
# every cell, so the medians collapse onto a single value.
|
|
432
|
+
# Measured over the real captures: iconcrush and gobang 0.0-0.8,
|
|
433
|
+
# against 14.9 (recaptcha_grid_4x4) up to 93.0 (geetest_v4_nine)
|
|
434
|
+
# for the five true grid families. The threshold sits in an 18x
|
|
435
|
+
# gap, which is why it is a fixed number rather than a tuned one.
|
|
436
|
+
medians = []
|
|
437
|
+
for (x1, y1, x2, y2) in grid_boxes:
|
|
438
|
+
roi = img[y1:y2, x1:x2]
|
|
439
|
+
if roi.size:
|
|
440
|
+
medians.append(np.median(roi.reshape(-1, 3), axis=0))
|
|
441
|
+
if len(medians) >= 4:
|
|
442
|
+
spread = float(np.mean(np.std(np.array(medians), axis=0)))
|
|
443
|
+
self.debug.log(f"_is_real_grid: median-colour spread = {spread:.1f}")
|
|
444
|
+
if spread < 6.0:
|
|
445
|
+
self.debug.log(
|
|
446
|
+
f"_is_real_grid: every cell shares one background colour "
|
|
447
|
+
f"({spread:.1f} < 6.0) → reject (sprite board, not tiles)."
|
|
448
|
+
)
|
|
449
|
+
return False
|
|
450
|
+
|
|
302
451
|
# If the *whole* image is mostly flat (e.g. screenshotting the
|
|
303
452
|
# tiny hCaptcha anchor iframe with just the "I'm not a robot"
|
|
304
453
|
# text), find_grid hallucinates a 9-cell grid. Reject early.
|
|
@@ -430,5 +579,27 @@ class CaptchaSolver:
|
|
|
430
579
|
return ClickAction(action="click", target_bounding_boxes=bboxes)
|
|
431
580
|
|
|
432
581
|
|
|
433
|
-
def solve_captcha(
|
|
434
|
-
|
|
582
|
+
def solve_captcha(
|
|
583
|
+
media_path: str,
|
|
584
|
+
instruction: str = "",
|
|
585
|
+
*,
|
|
586
|
+
puzzle_source: str = "unknown",
|
|
587
|
+
retry_mode: Optional[str] = None,
|
|
588
|
+
text_mode: bool = False,
|
|
589
|
+
**kwargs: Any,
|
|
590
|
+
) -> Any:
|
|
591
|
+
"""Solve one captcha image. `kwargs` configure the solver; the arguments
|
|
592
|
+
named above are forwarded to `CaptchaSolver.solve`.
|
|
593
|
+
|
|
594
|
+
They are spelled out rather than left in `**kwargs` because everything in
|
|
595
|
+
`kwargs` goes to the CONSTRUCTOR. A caller passing `text_mode=True` — the
|
|
596
|
+
only way this one-shot API can say "the answer is a typed string, not a
|
|
597
|
+
place on the picture" — got `TypeError: __init__() got an unexpected keyword
|
|
598
|
+
argument`, so a static distorted-text image was unsolvable through the
|
|
599
|
+
public entry point. Only the `PageSolver` path could reach the text prompt,
|
|
600
|
+
and it decides `text_mode` from the DOM, which a bare image does not have.
|
|
601
|
+
"""
|
|
602
|
+
return CaptchaSolver(**kwargs).solve(
|
|
603
|
+
media_path, instruction,
|
|
604
|
+
puzzle_source=puzzle_source, retry_mode=retry_mode, text_mode=text_mode,
|
|
605
|
+
)
|
|
@@ -40,3 +40,73 @@ def timed(label: str, extra: Optional[str] = None) -> Iterator[None]:
|
|
|
40
40
|
print(f"[TIMING] {label}: {dt_ms:.2f}ms{suffix}", file=sys.stderr)
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
#: What a phase's time COUNTS AS, when asking how much of a solve was useful.
|
|
44
|
+
#:
|
|
45
|
+
#: Only two kinds of second are worth spending: one the model is thinking in,
|
|
46
|
+
#: and one the mouse is travelling in (which has to look human, so it cannot be
|
|
47
|
+
#: rushed). Everything else is the driver waiting on a clock, and every such
|
|
48
|
+
#: wait is a candidate for deletion or for overlapping with something real.
|
|
49
|
+
PRODUCTIVE = {"inference", "mouse"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PhaseBudget:
|
|
53
|
+
"""Where one solve's wall-clock went, by phase.
|
|
54
|
+
|
|
55
|
+
Exists because "the solve took 77s" is not actionable and "the settle
|
|
56
|
+
monitor spent 31s of it" is. Always on — it is a few dict updates per phase
|
|
57
|
+
against multi-second waits — but only PRINTED under CAPTCHA_TIMINGS=1.
|
|
58
|
+
|
|
59
|
+
Phases may nest (a burst contains its screenshots); only the outermost of a
|
|
60
|
+
given name accumulates, so the totals stay a partition of the solve rather
|
|
61
|
+
than double-counting.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self.totals: dict = {}
|
|
66
|
+
self.counts: dict = {}
|
|
67
|
+
self._open: list = []
|
|
68
|
+
self._t0 = time.perf_counter()
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def phase(self, name: str) -> Iterator[None]:
|
|
72
|
+
if name in self._open: # nested re-entry: attribute it to the outer one
|
|
73
|
+
yield
|
|
74
|
+
return
|
|
75
|
+
self._open.append(name)
|
|
76
|
+
t0 = time.perf_counter()
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
self._open.remove(name)
|
|
81
|
+
dt = (time.perf_counter() - t0) * 1000.0
|
|
82
|
+
self.totals[name] = self.totals.get(name, 0.0) + dt
|
|
83
|
+
self.counts[name] = self.counts.get(name, 0) + 1
|
|
84
|
+
|
|
85
|
+
def add(self, name: str, ms: float) -> None:
|
|
86
|
+
"""Record a measured span directly.
|
|
87
|
+
|
|
88
|
+
For blocks that would need re-indenting to sit under `phase()` — the
|
|
89
|
+
accounting is not worth reshaping working control flow over.
|
|
90
|
+
"""
|
|
91
|
+
self.totals[name] = self.totals.get(name, 0.0) + ms
|
|
92
|
+
self.counts[name] = self.counts.get(name, 0) + 1
|
|
93
|
+
|
|
94
|
+
def elapsed_ms(self) -> float:
|
|
95
|
+
return (time.perf_counter() - self._t0) * 1000.0
|
|
96
|
+
|
|
97
|
+
def report(self) -> str:
|
|
98
|
+
total = self.elapsed_ms()
|
|
99
|
+
useful = sum(v for k, v in self.totals.items() if k in PRODUCTIVE)
|
|
100
|
+
rows = sorted(self.totals.items(), key=lambda kv: -kv[1])
|
|
101
|
+
lines = [f"[BUDGET] solve {total / 1000:.1f}s — "
|
|
102
|
+
f"{useful / 1000:.1f}s useful ({100 * useful / total if total else 0:.0f}%), "
|
|
103
|
+
f"{(total - useful) / 1000:.1f}s waiting"]
|
|
104
|
+
for name, ms in rows:
|
|
105
|
+
tag = "*" if name in PRODUCTIVE else " "
|
|
106
|
+
lines.append(f"[BUDGET] {tag} {name:22s} {ms / 1000:6.2f}s x{self.counts[name]}")
|
|
107
|
+
unattributed = total - sum(self.totals.values())
|
|
108
|
+
if unattributed > 50:
|
|
109
|
+
lines.append(f"[BUDGET] {'(unattributed)':22s} {unattributed / 1000:6.2f}s")
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|