browser-automation-skill 0.71.0

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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/SECURITY.md +39 -0
  4. package/SKILL.md +206 -0
  5. package/bin/cli.mjs +55 -0
  6. package/install.sh +143 -0
  7. package/package.json +54 -0
  8. package/references/adapter-candidates.md +40 -0
  9. package/references/browser-mcp-cheatsheet.md +132 -0
  10. package/references/browser-stats-cheatsheet.md +155 -0
  11. package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
  12. package/references/midscene-integration.md +359 -0
  13. package/references/obscura-cheatsheet.md +103 -0
  14. package/references/playwright-cli-cheatsheet.md +64 -0
  15. package/references/playwright-lib-cheatsheet.md +90 -0
  16. package/references/recipes/add-a-tool-adapter.md +134 -0
  17. package/references/recipes/agent-workflows/README.md +37 -0
  18. package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
  19. package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
  20. package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
  21. package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
  22. package/references/recipes/anti-patterns-tool-extension.md +182 -0
  23. package/references/recipes/body-bytes-not-body.md +139 -0
  24. package/references/recipes/cache-write-security.md +210 -0
  25. package/references/recipes/fingerprint-rescue.md +154 -0
  26. package/references/recipes/model-routing.md +143 -0
  27. package/references/recipes/path-security.md +138 -0
  28. package/references/recipes/privacy-canary.md +96 -0
  29. package/references/recipes/visual-rescue-hook.md +182 -0
  30. package/references/stats-prices.json +42 -0
  31. package/references/stats-schema.json +77 -0
  32. package/references/tool-versions.md +8 -0
  33. package/scripts/browser-add-site.sh +113 -0
  34. package/scripts/browser-assert.sh +106 -0
  35. package/scripts/browser-audit.sh +68 -0
  36. package/scripts/browser-baseline.sh +135 -0
  37. package/scripts/browser-click.sh +100 -0
  38. package/scripts/browser-creds-add.sh +254 -0
  39. package/scripts/browser-creds-list.sh +67 -0
  40. package/scripts/browser-creds-migrate.sh +122 -0
  41. package/scripts/browser-creds-remove.sh +69 -0
  42. package/scripts/browser-creds-rotate-totp.sh +109 -0
  43. package/scripts/browser-creds-show.sh +82 -0
  44. package/scripts/browser-creds-totp.sh +94 -0
  45. package/scripts/browser-do.sh +630 -0
  46. package/scripts/browser-doctor.sh +365 -0
  47. package/scripts/browser-drag.sh +90 -0
  48. package/scripts/browser-extract.sh +192 -0
  49. package/scripts/browser-fill.sh +142 -0
  50. package/scripts/browser-flow.sh +316 -0
  51. package/scripts/browser-history.sh +187 -0
  52. package/scripts/browser-hover.sh +92 -0
  53. package/scripts/browser-inspect.sh +188 -0
  54. package/scripts/browser-list-sessions.sh +78 -0
  55. package/scripts/browser-list-sites.sh +42 -0
  56. package/scripts/browser-login.sh +279 -0
  57. package/scripts/browser-mcp.sh +65 -0
  58. package/scripts/browser-migrate.sh +195 -0
  59. package/scripts/browser-open.sh +134 -0
  60. package/scripts/browser-press.sh +80 -0
  61. package/scripts/browser-remove-session.sh +72 -0
  62. package/scripts/browser-remove-site.sh +68 -0
  63. package/scripts/browser-replay.sh +206 -0
  64. package/scripts/browser-route.sh +174 -0
  65. package/scripts/browser-select.sh +122 -0
  66. package/scripts/browser-show-session.sh +57 -0
  67. package/scripts/browser-show-site.sh +37 -0
  68. package/scripts/browser-snapshot.sh +176 -0
  69. package/scripts/browser-stats.sh +522 -0
  70. package/scripts/browser-tab-close.sh +112 -0
  71. package/scripts/browser-tab-list.sh +70 -0
  72. package/scripts/browser-tab-switch.sh +111 -0
  73. package/scripts/browser-upload.sh +132 -0
  74. package/scripts/browser-use.sh +60 -0
  75. package/scripts/browser-vlm.sh +707 -0
  76. package/scripts/browser-wait.sh +97 -0
  77. package/scripts/install-git-hooks.sh +16 -0
  78. package/scripts/lib/capture.sh +356 -0
  79. package/scripts/lib/common.sh +262 -0
  80. package/scripts/lib/credential.sh +237 -0
  81. package/scripts/lib/fingerprint-rescue.js +123 -0
  82. package/scripts/lib/flow.sh +448 -0
  83. package/scripts/lib/flow_record.sh +210 -0
  84. package/scripts/lib/mask.sh +49 -0
  85. package/scripts/lib/memory.sh +427 -0
  86. package/scripts/lib/migrate.sh +390 -0
  87. package/scripts/lib/migrators/README.md +23 -0
  88. package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
  89. package/scripts/lib/migrators/recent_urls/README.md +13 -0
  90. package/scripts/lib/migrators/stats/README.md +24 -0
  91. package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
  92. package/scripts/lib/node/mcp-server.mjs +531 -0
  93. package/scripts/lib/node/mcp-tools.json +68 -0
  94. package/scripts/lib/node/playwright-driver.mjs +1104 -0
  95. package/scripts/lib/node/totp-core.mjs +52 -0
  96. package/scripts/lib/node/totp.mjs +52 -0
  97. package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
  98. package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
  99. package/scripts/lib/output.sh +79 -0
  100. package/scripts/lib/router.sh +342 -0
  101. package/scripts/lib/sanitize.sh +107 -0
  102. package/scripts/lib/secret/keychain.sh +91 -0
  103. package/scripts/lib/secret/libsecret.sh +74 -0
  104. package/scripts/lib/secret/plaintext.sh +75 -0
  105. package/scripts/lib/secret_backend_select.sh +57 -0
  106. package/scripts/lib/session.sh +153 -0
  107. package/scripts/lib/site.sh +126 -0
  108. package/scripts/lib/stats.sh +419 -0
  109. package/scripts/lib/tool/.gitkeep +0 -0
  110. package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
  111. package/scripts/lib/tool/obscura.sh +249 -0
  112. package/scripts/lib/tool/playwright-cli.sh +155 -0
  113. package/scripts/lib/tool/playwright-lib.sh +106 -0
  114. package/scripts/lib/verb_helpers.sh +222 -0
  115. package/scripts/lib/visual-rescue-default.sh +145 -0
  116. package/scripts/regenerate-docs.sh +99 -0
  117. package/uninstall.sh +51 -0
@@ -0,0 +1,52 @@
1
+ // scripts/lib/node/totp-core.mjs — pure-node RFC 6238 TOTP primitives.
2
+ //
3
+ // Extracted from totp.mjs (phase-5 part 4-ii) so playwright-driver.mjs can
4
+ // import the same logic for auto-replay (phase-5 part 4-iii). totp.mjs
5
+ // remains the CLI shim that reads stdin and calls totpAt().
6
+ //
7
+ // No external deps — uses node's built-in crypto module.
8
+
9
+ import { createHmac } from 'node:crypto';
10
+
11
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
12
+
13
+ export function base32Decode(b32) {
14
+ const cleaned = b32.toUpperCase().replace(/=+$/g, '').replace(/\s+/g, '');
15
+ let bits = '';
16
+ for (const ch of cleaned) {
17
+ const i = BASE32_ALPHABET.indexOf(ch);
18
+ if (i < 0) throw new Error(`invalid base32 character: '${ch}'`);
19
+ bits += i.toString(2).padStart(5, '0');
20
+ }
21
+ const bytes = [];
22
+ for (let i = 0; i + 8 <= bits.length; i += 8) {
23
+ bytes.push(parseInt(bits.slice(i, i + 8), 2));
24
+ }
25
+ return Buffer.from(bytes);
26
+ }
27
+
28
+ // totpAt — produce a TOTP code for a given timestamp (seconds since epoch).
29
+ // Defaults: 6 digits, 30s period, HMAC-SHA1 (per common TOTP issuer practice).
30
+ export function totpAt(secretBase32, timestampSec, digits = 6, period = 30, alg = 'sha1') {
31
+ const counter = Math.floor(timestampSec / period);
32
+ const counterBuf = Buffer.alloc(8);
33
+ // Big-endian 64-bit counter. Math.floor + lossy 32-bit handling for the
34
+ // upper word — acceptable for any timestamp before year ~2106.
35
+ counterBuf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
36
+ counterBuf.writeUInt32BE(counter >>> 0, 4);
37
+ const key = base32Decode(secretBase32);
38
+ const hmac = createHmac(alg, key).update(counterBuf).digest();
39
+ const offset = hmac[hmac.length - 1] & 0x0f;
40
+ const truncated =
41
+ ((hmac[offset] & 0x7f) << 24) |
42
+ (hmac[offset + 1] << 16) |
43
+ (hmac[offset + 2] << 8) |
44
+ hmac[offset + 3];
45
+ const code = truncated % Math.pow(10, digits);
46
+ return String(code).padStart(digits, '0');
47
+ }
48
+
49
+ // totpNow — convenience wrapper using Date.now().
50
+ export function totpNow(secretBase32, digits = 6, period = 30, alg = 'sha1') {
51
+ return totpAt(secretBase32, Math.floor(Date.now() / 1000), digits, period, alg);
52
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lib/node/totp.mjs — CLI shim around scripts/lib/node/totp-core.mjs.
3
+ //
4
+ // Reads base32-encoded shared secret from stdin (typical TOTP issuer output:
5
+ // "JBSWY3DPEHPK3PXP" etc.), produces the 6-digit code for the current 30s
6
+ // window. Core logic lives in totp-core.mjs so playwright-driver.mjs can
7
+ // import the same primitives for auto-replay (phase-5 part 4-iii).
8
+ //
9
+ // CLI:
10
+ // echo -n 'BASE32SECRET' | node totp.mjs
11
+ // → 6-digit code on stdout
12
+ //
13
+ // Optional env vars:
14
+ // TOTP_TIME_T (integer seconds since epoch) — override "now" for tests.
15
+ // Lets bats verify against RFC 6238 §A test vectors.
16
+ // TOTP_DIGITS (default 6) — code length.
17
+ // TOTP_PERIOD (default 30) — time-step in seconds.
18
+ // TOTP_ALG (default SHA1) — HMAC algorithm. Most providers use SHA1.
19
+
20
+ import { totpAt } from './totp-core.mjs';
21
+
22
+ async function readAllStdin() {
23
+ return new Promise((resolve, reject) => {
24
+ let data = '';
25
+ process.stdin.setEncoding('utf-8');
26
+ process.stdin.on('data', (chunk) => { data += chunk; });
27
+ process.stdin.on('end', () => resolve(data));
28
+ process.stdin.on('error', reject);
29
+ });
30
+ }
31
+
32
+ const secret = (await readAllStdin()).trim();
33
+ if (!secret) {
34
+ process.stderr.write('totp: empty secret on stdin\n');
35
+ process.exit(2);
36
+ }
37
+
38
+ const t = process.env.TOTP_TIME_T
39
+ ? parseInt(process.env.TOTP_TIME_T, 10)
40
+ : Math.floor(Date.now() / 1000);
41
+ const digits = parseInt(process.env.TOTP_DIGITS || '6', 10);
42
+ const period = parseInt(process.env.TOTP_PERIOD || '30', 10);
43
+ const alg = (process.env.TOTP_ALG || 'sha1').toLowerCase();
44
+
45
+ try {
46
+ const code = totpAt(secret, t, digits, period, alg);
47
+ process.stdout.write(code + '\n');
48
+ process.exit(0);
49
+ } catch (err) {
50
+ process.stderr.write(`totp: ${err && err.message ? err.message : err}\n`);
51
+ process.exit(1);
52
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lib/node/url-pattern-cluster.mjs
3
+ //
4
+ // Phase 11 part 2-ii. Cluster URLs by templated pathname.
5
+ //
6
+ // Stdin: {"urls": ["https://...", ...]}
7
+ // Stdout: {"clusters": [{"templated": "/devices/:id", "urls": [...], "count": N}, ...]}
8
+ //
9
+ // Heuristic:
10
+ // numeric segment (^[0-9]+$) → :id
11
+ // UUID segment (8-4-4-4-12 hex) → :uuid
12
+ // slug segment (^[a-z0-9_]+(-[a-z0-9_]+)+$/i + length ≥ 5) → :slug
13
+ // other segments → verbatim
14
+ //
15
+ // Slug heuristic (Pick A2) — locked decision:
16
+ // - Requires at least ONE hyphen separating alphanumeric groups.
17
+ // - Each side of every hyphen must be ≥1 char of [a-zA-Z0-9_].
18
+ // - Total segment length ≥ 5 chars (filters short codes like `a-b` or
19
+ // `1-2` which are more likely to be opaque identifiers than slugs).
20
+ // - All-numeric is already caught by the numeric branch above (which
21
+ // fires before slug detection in this `if`-chain order).
22
+ //
23
+ // Cross-site clustering not in scope (caller passes per-site URLs).
24
+
25
+ let stdin = "";
26
+ process.stdin.setEncoding("utf8");
27
+ for await (const chunk of process.stdin) stdin += chunk;
28
+
29
+ let payload;
30
+ try {
31
+ payload = JSON.parse(stdin || "{}");
32
+ } catch {
33
+ process.stdout.write(JSON.stringify({ clusters: [] }));
34
+ process.exit(0);
35
+ }
36
+
37
+ const urls = Array.isArray(payload.urls) ? payload.urls : [];
38
+
39
+ const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
40
+ const NUMERIC_RE = /^[0-9]+$/;
41
+ const SLUG_RE = /^[a-z0-9_]+(-[a-z0-9_]+)+$/i;
42
+ const MIN_SLUG_LEN = 5;
43
+
44
+ function templatePathname(pathname) {
45
+ // Split preserves the leading "/" as an empty first element.
46
+ const parts = pathname.split("/");
47
+ const templated = parts.map((seg) => {
48
+ if (seg === "") return seg;
49
+ if (UUID_RE.test(seg)) return ":uuid";
50
+ if (NUMERIC_RE.test(seg)) return ":id";
51
+ // Pick A2: slug heuristic. Fires after numeric/UUID; only on hyphenated
52
+ // multi-group segments of length >= MIN_SLUG_LEN.
53
+ if (seg.length >= MIN_SLUG_LEN && SLUG_RE.test(seg)) return ":slug";
54
+ return seg;
55
+ });
56
+ return templated.join("/");
57
+ }
58
+
59
+ const buckets = new Map();
60
+ for (const url of urls) {
61
+ if (typeof url !== "string") continue;
62
+ let pathname;
63
+ try {
64
+ pathname = new URL(url, "https://placeholder.local").pathname;
65
+ } catch {
66
+ continue;
67
+ }
68
+ const templated = templatePathname(pathname);
69
+ // Skip URLs whose templated form is identical to the original (no
70
+ // numeric/UUID segment matched) AND that haven't been seen before. This
71
+ // is what filters slug-shaped segments out of cluster proposals.
72
+ // (Identical templates DO still get bucketed if they collide; only the
73
+ // single-occurrence non-template URLs get suppressed below by the
74
+ // threshold filter.)
75
+ const bucket = buckets.get(templated) || [];
76
+ bucket.push(url);
77
+ buckets.set(templated, bucket);
78
+ }
79
+
80
+ // Only emit clusters where the templated form differs from at least one
81
+ // constituent URL's pathname — otherwise it's just N copies of the same
82
+ // literal URL, which isn't a "pattern".
83
+ const clusters = [];
84
+ for (const [templated, urlList] of buckets) {
85
+ let hasTemplating = false;
86
+ for (const url of urlList) {
87
+ let pathname;
88
+ try {
89
+ pathname = new URL(url, "https://placeholder.local").pathname;
90
+ } catch {
91
+ continue;
92
+ }
93
+ if (pathname !== templated) {
94
+ hasTemplating = true;
95
+ break;
96
+ }
97
+ }
98
+ if (!hasTemplating) continue;
99
+ clusters.push({ templated, urls: urlList, count: urlList.length });
100
+ }
101
+
102
+ process.stdout.write(JSON.stringify({ clusters }));
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lib/node/url-pattern-resolver.mjs
3
+ //
4
+ // Phase 11 part 1-i. URL → archetype resolution.
5
+ //
6
+ // Stdin: {"patterns":[{"url_pattern":"/devices/:id","archetype_id":"…"}], "url":"https://…"}
7
+ // Stdout: {"matched_pattern":"/devices/:id","archetype_id":"devices-detail"} on hit
8
+ // null on miss
9
+ //
10
+ // First-match-wins (callers reorder patterns to express priority).
11
+ // Pattern is a pathname pattern; matched against the URL's pathname (URL
12
+ // parsed with a placeholder origin so relative URLs work).
13
+ //
14
+ // Matcher subset (deliberate; v1):
15
+ // :name → matches one path segment (non-slash chars)
16
+ // * → matches any chars including slashes
17
+ // literal → matched verbatim
18
+ //
19
+ // Why not the URLPattern web standard? Because the global `URLPattern` is
20
+ // only stable in Node 23.8+; GitHub Actions runners still default to Node
21
+ // 20 (see https://github.blog/changelog/2025-09-19-...). A hand-rolled
22
+ // matcher keeps behavior deterministic across all supported Node versions
23
+ // and removes the npm-polyfill cost. URLPattern can replace this when the
24
+ // CI baseline lifts to Node 24+ (target: mid-2026).
25
+
26
+ let stdin = "";
27
+ process.stdin.setEncoding("utf8");
28
+ for await (const chunk of process.stdin) stdin += chunk;
29
+
30
+ let payload;
31
+ try {
32
+ payload = JSON.parse(stdin || "{}");
33
+ } catch {
34
+ process.stdout.write("null");
35
+ process.exit(0);
36
+ }
37
+
38
+ const patterns = Array.isArray(payload.patterns) ? payload.patterns : [];
39
+ const url = typeof payload.url === "string" ? payload.url : "";
40
+
41
+ let pathname;
42
+ try {
43
+ pathname = new URL(url, "https://placeholder.local").pathname;
44
+ } catch {
45
+ process.stdout.write("null");
46
+ process.exit(0);
47
+ }
48
+
49
+ // Compile pattern → RegExp. :name matches one segment; * matches anything.
50
+ function compile(pattern) {
51
+ // Escape regex metachars EXCEPT the two we re-introduce (`:` and `*`).
52
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
53
+ // :name → [^/]+ (one segment)
54
+ const withNamed = escaped.replace(/:[A-Za-z_][\w$]*/g, "[^/]+");
55
+ // * → .* (anything, slashes included)
56
+ const withStar = withNamed.replace(/\*/g, ".*");
57
+ return new RegExp("^" + withStar + "/?$");
58
+ }
59
+
60
+ for (const p of patterns) {
61
+ if (typeof p?.url_pattern !== "string") continue;
62
+ let re;
63
+ try {
64
+ re = compile(p.url_pattern);
65
+ } catch {
66
+ continue;
67
+ }
68
+ if (re.test(pathname)) {
69
+ process.stdout.write(JSON.stringify({
70
+ matched_pattern: p.url_pattern,
71
+ archetype_id: p.archetype_id,
72
+ }));
73
+ process.exit(0);
74
+ }
75
+ }
76
+
77
+ process.stdout.write("null");
@@ -0,0 +1,79 @@
1
+ # scripts/lib/output.sh
2
+ # Token-efficient adapter output helpers. Implements the contract from
3
+ # docs/superpowers/specs/2026-05-01-token-efficient-adapter-output-design.md §3.
4
+ #
5
+ # Verbs and adapters MUST emit through these helpers — never hand-roll JSON.
6
+ # Lint tier 3 (tests/lint.sh, Phase 3 Task 13) enforces it.
7
+
8
+ [ -n "${BROWSER_SKILL_OUTPUT_LOADED:-}" ] && return 0
9
+ readonly BROWSER_SKILL_OUTPUT_LOADED=1
10
+
11
+ # Canonical status values (spec §3.1). Reject anything else at the helper boundary
12
+ # so output stays parseable by jq routing logic.
13
+ readonly _OUTPUT_STATUSES_OK="ok partial empty error aborted"
14
+
15
+ # emit_summary key=value [key=value ...]
16
+ # Required keys: verb, tool, why, status. duration_ms auto-fills from
17
+ # SUMMARY_T0 (set by caller via `SUMMARY_T0=$(now_ms)` at verb entry).
18
+ # Wraps summary_json from common.sh; adds key-presence + status-enum guards.
19
+ emit_summary() {
20
+ local has_verb=0 has_tool=0 has_why=0 has_status=0 has_duration=0
21
+ local arg value
22
+ for arg in "$@"; do
23
+ case "${arg}" in
24
+ verb=*) has_verb=1 ;;
25
+ tool=*) has_tool=1 ;;
26
+ why=*) has_why=1 ;;
27
+ status=*)
28
+ has_status=1
29
+ value="${arg#status=}"
30
+ if ! [[ " ${_OUTPUT_STATUSES_OK} " == *" ${value} "* ]]; then
31
+ die "${EXIT_USAGE_ERROR}" "emit_summary: status='${value}' not in {${_OUTPUT_STATUSES_OK// /, }}"
32
+ fi
33
+ ;;
34
+ duration_ms=*) has_duration=1 ;;
35
+ esac
36
+ done
37
+
38
+ [ "${has_verb}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'verb'"
39
+ [ "${has_tool}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'tool'"
40
+ [ "${has_why}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'why'"
41
+ [ "${has_status}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'status'"
42
+
43
+ if [ "${has_duration}" = "0" ] && [ -n "${SUMMARY_T0:-}" ]; then
44
+ local now elapsed
45
+ now="$(now_ms)"
46
+ elapsed=$((now - SUMMARY_T0))
47
+ summary_json "$@" "duration_ms=${elapsed}"
48
+ return
49
+ fi
50
+
51
+ summary_json "$@"
52
+ }
53
+
54
+ # emit_event EVENT_NAME [key=value ...]
55
+ # Streaming JSON line with `.event = EVENT_NAME`. Spec §3.3.
56
+ emit_event() {
57
+ local event="${1:-}"
58
+ shift || true
59
+ [ -n "${event}" ] || die "${EXIT_USAGE_ERROR}" "emit_event: empty event name"
60
+ summary_json "event=${event}" "$@"
61
+ }
62
+
63
+ # capture_path CATEGORY SITE EXT
64
+ # Returns ${CAPTURES_DIR}/<category>/<site>--<ts>.<ext> and mkdir -p's the parent.
65
+ # CATEGORY: snapshots | screenshots | hars | traces | videos | pdfs (spec §6).
66
+ # SITE: must pass assert_safe_name (no traversal).
67
+ # EXT: file extension without dot (e.g. png, har, yaml, webm, pdf, zip).
68
+ capture_path() {
69
+ local category="$1" site="$2" ext="$3"
70
+ assert_safe_name "${category}" "capture-category"
71
+ assert_safe_name "${site}" "site-name"
72
+ assert_safe_name "${ext}" "capture-extension"
73
+
74
+ local ts
75
+ ts="$(date -u +%Y-%m-%dT%H%M%SZ)"
76
+ local dir="${CAPTURES_DIR:?CAPTURES_DIR not set; call init_paths first}/${category}"
77
+ mkdir -p "${dir}"
78
+ printf '%s/%s--%s.%s\n' "${dir}" "${site}" "${ts}" "${ext}"
79
+ }