akm-cli 0.9.0-rc.9 → 0.9.1-beta.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/CHANGELOG.md +1770 -44
- package/README.md +38 -47
- package/SECURITY.md +14 -1
- package/STABILITY.md +499 -0
- package/dist/akm +148 -35
- package/dist/{akm-migrate-storage → akm-migrate} +6 -9
- package/dist/assets/hints/cli-hints-full.md +223 -95
- package/dist/assets/hints/cli-hints-short.md +85 -22
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/reflect-distill.json +1 -1
- package/dist/assets/prompts/memory-infer-user.md +2 -3
- package/dist/assets/stash-skeleton/README.md +6 -5
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +20 -9
- package/dist/assets/tasks/core/extract.yml +1 -1
- package/dist/assets/tasks/core/version-check.yml +1 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
- package/dist/assets/templates/html/health.html +1 -3
- package/dist/assets/workflows/workflow-template.md +32 -15
- package/dist/cli/invocation.js +40 -15
- package/dist/cli/parse-args.js +0 -22
- package/dist/cli/retired-commands.js +121 -0
- package/dist/cli/shared.js +154 -22
- package/dist/cli/unknown-flags.js +236 -0
- package/dist/cli-node.mjs +2 -1
- package/dist/cli.js +696 -258
- package/dist/commands/agent/agent-dispatch.js +14 -3
- package/dist/commands/agent/contribute-cli.js +74 -89
- package/dist/commands/completions.js +79 -22
- package/dist/commands/config-cli.js +17 -150
- package/dist/commands/env/env-cli.js +59 -143
- package/dist/commands/env/env.js +12 -163
- package/dist/commands/env/marker-path.js +6 -0
- package/dist/commands/env/secret-cli.js +36 -66
- package/dist/commands/env/secret.js +24 -57
- package/dist/commands/feedback-cli.js +148 -88
- package/dist/commands/health/accept-rate.js +58 -0
- package/dist/commands/health/advisories.js +3 -4
- package/dist/commands/health/checks.js +85 -23
- package/dist/commands/health/html-report.js +7 -10
- package/dist/commands/health/improve-metrics.js +25 -83
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/md-report.js +5 -9
- package/dist/commands/health/metrics.js +62 -20
- package/dist/commands/health/renderers.js +47 -0
- package/dist/commands/health/report-view-model.js +4 -5
- package/dist/commands/health/stash-exposure.js +1 -1
- package/dist/commands/health/surfaces.js +4 -122
- package/dist/commands/health/task-runs.js +3 -67
- package/dist/commands/health/types-improve.js +7 -0
- package/dist/commands/health.js +160 -35
- package/dist/commands/improve/anti-collapse.js +2 -2
- package/dist/commands/improve/autonomy-gate.js +68 -0
- package/dist/commands/improve/collapse-detector.js +41 -40
- package/dist/commands/improve/consolidate/eligibility.js +1 -23
- package/dist/commands/improve/consolidate/merge.js +4 -0
- package/dist/commands/improve/consolidate.js +140 -1000
- package/dist/commands/improve/distill/promote-memory.js +12 -12
- package/dist/commands/improve/distill/quality-gate.js +11 -6
- package/dist/commands/improve/distill.js +58 -69
- package/dist/commands/improve/eligibility.js +101 -55
- package/dist/commands/improve/extract-cli.js +14 -133
- package/dist/commands/improve/improve-cli.js +98 -114
- package/dist/commands/improve/improve-result-file.js +1 -28
- package/dist/commands/improve/improve-strategies.js +8 -5
- package/dist/commands/improve/improve.js +254 -101
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/loop-stages.js +182 -20
- package/dist/commands/improve/memory/derived-ref.js +45 -43
- package/dist/commands/improve/memory/memory-belief.js +1 -1
- package/dist/commands/improve/memory/memory-contradiction-detect.js +4 -12
- package/dist/commands/improve/memory/memory-improve.js +14 -5
- package/dist/commands/improve/outcome-loop.js +22 -65
- package/dist/commands/improve/preparation.js +114 -123
- package/dist/commands/improve/proactive-maintenance.js +2 -5
- package/dist/commands/improve/reflect.js +56 -160
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/salience.js +11 -122
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/improve/source-identity.js +10 -38
- package/dist/commands/lint/base-linter.js +51 -131
- package/dist/commands/lint/env-key-rules.js +31 -47
- package/dist/commands/lint/index.js +444 -88
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/{events.js → log.js} +33 -38
- package/dist/commands/migrate-cli.js +92 -12
- package/dist/commands/migration-tool.js +46 -0
- package/dist/commands/observability-cli.js +70 -209
- package/dist/commands/proposal/drain.js +101 -29
- package/dist/commands/proposal/proposal-cli.js +76 -48
- package/dist/commands/proposal/proposal.js +54 -18
- package/dist/commands/proposal/propose-cli.js +88 -0
- package/dist/commands/proposal/propose.js +23 -15
- package/dist/commands/proposal/repository.js +718 -279
- package/dist/commands/proposal/validators/proposal-quality-validators.js +2 -8
- package/dist/commands/proposal/validators/proposal-validators.js +55 -7
- package/dist/commands/proposal/validators/proposals.js +4 -7
- package/dist/commands/read/curate.js +34 -53
- package/dist/commands/read/knowledge.js +150 -95
- package/dist/commands/read/registry-search.js +2 -2
- package/dist/commands/read/remember-cli.js +42 -15
- package/dist/commands/read/search-cli.js +180 -78
- package/dist/commands/read/search.js +58 -43
- package/dist/commands/read/show.js +197 -141
- package/dist/commands/registry-cli.js +12 -51
- package/dist/commands/remember.js +14 -57
- package/dist/commands/sources/add-cli.js +100 -31
- package/dist/commands/sources/bundle-cli.js +166 -0
- package/dist/commands/sources/bundle-config-ops.js +7 -2
- package/dist/commands/sources/info.js +30 -7
- package/dist/commands/sources/init.js +12 -12
- package/dist/commands/sources/installed-stashes.js +387 -98
- package/dist/commands/sources/schema-repair.js +3 -2
- package/dist/commands/sources/self-update.js +131 -38
- package/dist/commands/sources/source-add.js +72 -17
- package/dist/commands/sources/source-clone.js +129 -45
- package/dist/commands/sources/source-manage.js +43 -23
- package/dist/commands/sources/sources-cli.js +57 -208
- package/dist/commands/sources/stash-cli.js +46 -53
- package/dist/commands/tasks/tasks-cli.js +91 -97
- package/dist/commands/tasks/tasks.js +284 -423
- package/dist/commands/workflow-cli.js +181 -450
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
- package/dist/core/adapter/adapters/akm-adapter.js +60 -38
- package/dist/core/adapter/adapters/akm-lint.js +109 -42
- package/dist/core/adapter/adapters/akm-metadata.js +15 -44
- package/dist/core/adapter/adapters/akm-task-adapter.js +54 -31
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +55 -71
- package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
- package/dist/core/adapter/adapters/generic-files-adapter.js +2 -0
- package/dist/core/adapter/adapters/index.js +6 -6
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +14 -8
- package/dist/core/adapter/adapters/okf-adapter.js +187 -19
- package/dist/core/adapter/adapters/shared.js +3 -19
- package/dist/core/adapter/adapters/tool-dir-shared.js +13 -6
- package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -0
- package/dist/core/adapter/detect-adapter.js +17 -0
- package/dist/core/adapter/recognize-match.js +6 -4
- package/dist/core/adapter/validate-context.js +214 -0
- package/dist/core/asset/akm-markdown.js +63 -0
- package/dist/core/asset/asset-placement.js +20 -6
- package/dist/core/asset/asset-ref.js +11 -9
- package/dist/core/asset/frontmatter-lint.js +30 -0
- package/dist/core/asset/frontmatter.js +29 -9
- package/dist/core/asset/markdown.js +40 -51
- package/dist/core/asset/resolve-ref.js +89 -18
- package/dist/core/asset/stash-meta.js +1 -1
- package/dist/core/bundle-id.js +51 -0
- package/dist/core/common.js +218 -44
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +12 -1
- package/dist/core/config/config-schema.js +35 -8
- package/dist/core/config/config-sources.js +55 -11
- package/dist/core/config/config-walker.js +25 -9
- package/dist/core/config/config.js +9 -48
- package/dist/core/config/experimental.js +21 -0
- package/dist/core/config/schema/embedding.js +5 -1
- package/dist/core/config/schema/experimental.js +30 -0
- package/dist/core/config/schema/improve-processes.js +0 -6
- package/dist/core/config/schema/improve.js +21 -3
- package/dist/core/config/schema/index-config.js +8 -15
- package/dist/core/config/schema/output.js +4 -1
- package/dist/core/config/schema/setup.js +9 -18
- package/dist/core/config/schema/sources-bundles.js +49 -33
- package/dist/core/config/schema/workflow.js +14 -3
- package/dist/core/env-secret-ref.js +76 -46
- package/dist/core/errors.js +43 -12
- package/dist/core/events.js +76 -152
- package/dist/core/file-change.js +6 -5
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +83 -7
- package/dist/core/git-message.js +2 -2
- package/dist/core/improve-result.js +6 -100
- package/dist/core/json-schema.js +327 -9
- package/dist/core/lesson-lint.js +1 -17
- package/dist/core/logs-db.js +2 -1
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +33 -2
- package/dist/core/mutation-target.js +78 -0
- package/dist/core/parse.js +4 -1
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +17 -20
- package/dist/core/recognition-util.js +12 -14
- package/dist/core/redaction.js +98 -17
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/standards/resolve-standards-context.js +2 -14
- package/dist/core/standards/resolve-stash-standards.js +2 -2
- package/dist/core/standards/resolve-type-conventions.js +2 -2
- package/dist/core/state/migrations.js +41 -18
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +5 -14
- package/dist/core/structured.js +1 -1
- package/dist/core/subprocess.js +182 -36
- package/dist/core/text-truncation.js +9 -5
- package/dist/core/type-presentation.js +3 -3
- package/dist/core/warn.js +0 -3
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +778 -95
- package/dist/indexer/bundle-identity-guard.js +3 -2
- package/dist/indexer/db/graph-db.js +17 -30
- package/dist/indexer/ensure-index.js +11 -3
- package/dist/indexer/graph/graph-boost.js +9 -34
- package/dist/indexer/graph/graph-extraction.js +8 -5
- package/dist/indexer/index-writer-lock.js +53 -17
- package/dist/indexer/index-written-assets.js +33 -24
- package/dist/indexer/indexer.js +551 -253
- package/dist/indexer/installations.js +14 -96
- package/dist/indexer/passes/dir-staleness.js +16 -9
- package/dist/indexer/passes/memory-inference.js +15 -9
- package/dist/indexer/passes/metadata.js +113 -47
- package/dist/indexer/scan/doc-to-entry.js +38 -1
- package/dist/indexer/scan/drain-dir.js +13 -23
- package/dist/indexer/search/db-search.js +107 -69
- package/dist/indexer/search/fts-query.js +47 -24
- package/dist/indexer/search/ranking-contributors.js +42 -20
- package/dist/indexer/search/ranking.js +18 -99
- package/dist/indexer/search/search-fields.js +7 -2
- package/dist/indexer/search/search-source.js +82 -93
- package/dist/indexer/usage/usage-events.js +0 -89
- package/dist/indexer/walk/file-context.js +2 -1
- package/dist/indexer/walk/matchers.js +30 -43
- package/dist/indexer/walk/path-resolver.js +7 -2
- package/dist/indexer/walk/walker.js +38 -12
- package/dist/integrations/agent/builders.js +0 -6
- package/dist/integrations/agent/config.js +2 -2
- package/dist/integrations/agent/detect.js +49 -19
- package/dist/integrations/agent/engine-fallback.js +76 -0
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +23 -1
- package/dist/integrations/agent/prompts.js +12 -8
- package/dist/integrations/agent/runner-dispatch.js +4 -2
- package/dist/integrations/agent/runner.js +0 -1
- package/dist/integrations/agent/spawn.js +20 -93
- package/dist/integrations/github.js +1 -1
- package/dist/integrations/harnesses/aider/agent-builder.js +6 -4
- package/dist/integrations/harnesses/amazonq/agent-builder.js +7 -4
- package/dist/integrations/harnesses/claude/session-log.js +0 -10
- package/dist/integrations/harnesses/codex/agent-builder.js +5 -2
- package/dist/integrations/harnesses/copilot/agent-builder.js +5 -3
- package/dist/integrations/harnesses/gemini/agent-builder.js +5 -3
- package/dist/integrations/harnesses/index.js +3 -7
- package/dist/integrations/harnesses/opencode/agent-builder.js +21 -2
- package/dist/integrations/harnesses/opencode/session-log.js +0 -15
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
- package/dist/integrations/harnesses/openhands/agent-builder.js +9 -6
- package/dist/integrations/harnesses/pi/agent-builder.js +6 -4
- package/dist/integrations/lockfile.js +132 -8
- package/dist/integrations/session-logs/index.js +3 -28
- package/dist/llm/client.js +136 -100
- package/dist/llm/embedders/remote.js +13 -5
- package/dist/llm/feature-gate.js +4 -12
- package/dist/llm/graph-extract.js +5 -11
- package/dist/llm/memory-infer.js +144 -1
- package/dist/llm/metadata-enhance.js +5 -7
- package/dist/llm/structured-call.js +1 -1
- package/dist/llm/usage-persist.js +26 -5
- package/dist/llm/usage-telemetry.js +25 -2
- package/dist/output/cli-hints.js +1 -2
- package/dist/output/context.js +22 -7
- package/dist/output/format-exempt.js +80 -0
- package/dist/output/generic-render.js +259 -0
- package/dist/output/render-registry.js +57 -0
- package/dist/output/renderers.js +14 -36
- package/dist/output/shapes/curate.js +10 -1
- package/dist/output/shapes/events.js +12 -7
- package/dist/output/shapes/helpers.js +56 -83
- package/dist/output/shapes/migrate.js +8 -0
- package/dist/output/shapes/passthrough.js +7 -41
- package/dist/output/shapes/proposal/producer.js +15 -7
- package/dist/output/shapes.js +2 -9
- package/dist/output/text/{init.js → bundle-create.js} +3 -1
- package/dist/output/text/bundle-show.js +7 -0
- package/dist/output/text/command-format.js +164 -96
- package/dist/output/text/env.js +1 -3
- package/dist/output/text/events.js +8 -7
- package/dist/output/text/health-format.js +103 -0
- package/dist/output/text/health.js +7 -0
- package/dist/output/text/helpers.js +10 -8
- package/dist/output/text/lint-format.js +56 -0
- package/dist/{migrate-storage-node.mjs → output/text/lint.js} +2 -5
- package/dist/output/text/migrate.js +88 -0
- package/dist/output/text/proposal/producer.js +4 -2
- package/dist/output/text/proposal-format.js +44 -72
- package/dist/output/text/registry-commands.js +1 -2
- package/dist/output/text/show-directives.js +15 -7
- package/dist/output/text/status-list.js +32 -0
- package/dist/output/text/{save.js → sync.js} +2 -2
- package/dist/output/text/workflow-format.js +24 -203
- package/dist/output/text/workflow.js +1 -7
- package/dist/output/text.js +16 -17
- package/dist/registry/factory.js +4 -6
- package/dist/registry/origin-resolve.js +16 -27
- package/dist/registry/providers/skills-sh.js +3 -3
- package/dist/registry/providers/static-index.js +13 -23
- package/dist/registry/resolve.js +42 -7
- package/dist/registry/semver.js +34 -84
- package/dist/runtime.js +2 -23
- package/dist/scripts/akm-migrate-node.js +60972 -0
- package/dist/scripts/akm-migrate.js +60310 -0
- package/dist/setup/detect.js +42 -15
- package/dist/setup/registry-stash-loader.js +2 -2
- package/dist/setup/setup.js +236 -136
- package/dist/setup/steps/connection.js +7 -9
- package/dist/setup/steps/platforms.js +9 -9
- package/dist/setup/steps/semantic.js +15 -3
- package/dist/setup/steps/sources.js +12 -13
- package/dist/setup/steps/stashdir.js +2 -3
- package/dist/setup/steps/tasks.js +237 -120
- package/dist/sources/freshness.js +1 -1
- package/dist/sources/provider-factory.js +11 -17
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +278 -34
- package/dist/sources/providers/git-provider.js +25 -23
- package/dist/sources/providers/git-stash.js +414 -106
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/providers/npm.js +16 -19
- package/dist/sources/providers/provider-utils.js +7 -4
- package/dist/sources/providers/sync-from-ref.js +3 -9
- package/dist/sources/providers/website.js +6 -1
- package/dist/sources/resolve.js +6 -5
- package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
- package/dist/sources/snapshot-fetchers/content-extract.js +566 -0
- package/dist/sources/snapshot-fetchers/fetcher-util.js +41 -0
- package/dist/sources/snapshot-fetchers/github.js +100 -0
- package/dist/sources/snapshot-fetchers/host-guard.js +291 -0
- package/dist/sources/snapshot-fetchers/registry.js +17 -1
- package/dist/sources/snapshot-fetchers/robots.js +348 -0
- package/dist/sources/snapshot-fetchers/rss.js +282 -0
- package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
- package/dist/sources/snapshot-fetchers/website-ingest.js +686 -282
- package/dist/sources/snapshot-fetchers/x.js +910 -0
- package/dist/storage/database.js +13 -1
- package/dist/storage/engines/sqlite-migrations.js +23 -111
- package/dist/storage/managed-db.js +21 -2
- package/dist/storage/repositories/canaries-repository.js +1 -1
- package/dist/storage/repositories/events-repository.js +27 -11
- package/dist/storage/repositories/improve-runs-repository.js +6 -12
- package/dist/storage/repositories/index-connection.js +44 -8
- package/dist/storage/repositories/index-entries-repository.js +157 -241
- package/dist/storage/repositories/index-entry-mapper.js +15 -11
- package/dist/storage/repositories/index-fts-repository.js +5 -2
- package/dist/storage/repositories/index-llm-cache-repository.js +0 -1
- package/dist/storage/repositories/index-meta-repository.js +2 -3
- package/dist/storage/repositories/index-schema.js +10 -25
- package/dist/storage/repositories/index-utility-repository.js +15 -28
- package/dist/storage/repositories/index-vec-repository.js +6 -1
- package/dist/storage/repositories/outcome-repository.js +119 -0
- package/dist/storage/repositories/proposals-repository.js +296 -59
- package/dist/storage/repositories/registry-cache.js +19 -0
- package/dist/storage/repositories/salience-repository.js +172 -0
- package/dist/storage/repositories/task-history-repository.js +15 -13
- package/dist/storage/repositories/workflow-runs-repository.js +118 -53
- package/dist/tasks/backends/cron.js +105 -15
- package/dist/tasks/backends/index.js +1 -1
- package/dist/tasks/backends/launchd.js +85 -38
- package/dist/tasks/backends/schtasks.js +135 -15
- package/dist/tasks/embedded.js +56 -40
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +87 -160
- package/dist/tasks/resolve-akm-bin.js +137 -59
- package/dist/tasks/runner.js +209 -44
- package/dist/tasks/scheduler-invocation.js +220 -10
- package/dist/tasks/schema.js +89 -1
- package/dist/tasks/task-id.js +1 -3
- package/dist/tasks/validator.js +20 -6
- package/dist/workflows/authoring/authoring.js +94 -143
- package/dist/workflows/authoring/scope-key.js +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +130 -32
- package/dist/workflows/exec/native-executor.js +513 -274
- package/dist/workflows/exec/param-secrets.js +12 -11
- package/dist/workflows/exec/run-workflow.js +496 -237
- package/dist/workflows/exec/step-work.js +616 -412
- package/dist/workflows/exec/unit-dispatch.js +34 -0
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +166 -271
- package/dist/workflows/ir/freeze.js +119 -42
- package/dist/workflows/ir/params.js +135 -11
- package/dist/workflows/ir/plan-hash.js +1 -1
- package/dist/workflows/ir/schema.js +129 -45
- package/dist/workflows/parser.js +1070 -307
- package/dist/workflows/program/expressions.js +20 -208
- package/dist/workflows/program/schema.js +31 -10
- package/dist/workflows/renderer.js +123 -68
- package/dist/workflows/resource-limits.js +184 -0
- package/dist/workflows/runtime/checkin.js +3 -3
- package/dist/workflows/runtime/plan-classifier.js +16 -75
- package/dist/workflows/runtime/runs.js +331 -132
- package/dist/workflows/runtime/unit-checkin.js +1 -1
- package/dist/workflows/runtime/unit-phases.js +2 -2
- package/dist/workflows/runtime/workflow-asset-loader.js +232 -83
- package/dist/workflows/schema.js +1 -11
- package/dist/workflows/validate-summary.js +47 -38
- package/dist/workflows/validator.js +21 -62
- package/docs/README.md +110 -0
- package/docs/migration/README.md +8 -0
- package/docs/migration/release-notes/0.7.0.md +11 -11
- package/docs/migration/release-notes/0.9.0.md +209 -27
- package/docs/migration/v0.7-to-v0.8.md +48 -48
- package/docs/migration/v0.8-to-v0.9.md +567 -209
- package/docs/migration/v0.9.0-troubleshooting.md +561 -0
- package/docs/reference/README.md +19 -0
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +2342 -0
- package/docs/reference/configuration.md +371 -0
- package/docs/reference/data-and-telemetry.md +129 -46
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +51 -0
- package/package.json +31 -13
- package/schemas/akm-asset-envelope.json +93 -0
- package/schemas/akm-config.json +89 -128
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +157 -81
- package/dist/assets/tasks/core/backup.yml +0 -5
- package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
- package/dist/cli/config-migrate.js +0 -1878
- package/dist/cli/config-validate.js +0 -41
- package/dist/commands/backup-cli.js +0 -56
- package/dist/commands/bundle/bundle-cli.js +0 -68
- package/dist/commands/bundle/bundle.js +0 -219
- package/dist/commands/graph/graph-cli.js +0 -124
- package/dist/commands/graph/graph.js +0 -489
- package/dist/commands/improve/extract-watch.js +0 -140
- package/dist/commands/mv-cli.js +0 -1221
- package/dist/commands/sources/history.js +0 -201
- package/dist/commands/tasks/default-tasks.js +0 -186
- package/dist/core/migration-backup.js +0 -1231
- package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -95
- package/dist/llm/memory-infer-impl.js +0 -138
- package/dist/migrate/legacy/config-source-migration.js +0 -257
- package/dist/migrate/legacy/content-migration.js +0 -350
- package/dist/migrate/legacy/legacy-layout.js +0 -779
- package/dist/migrate/legacy/legacy-paths.js +0 -25
- package/dist/migrate/legacy/legacy-stash-json.js +0 -80
- package/dist/migrate/legacy/proposal-fs-import.js +0 -168
- package/dist/migrate/legacy/task-target-ref-migration.js +0 -278
- package/dist/migrate/legacy/three-db-cutover.js +0 -845
- package/dist/migrate/legacy/workflow-migrations-bodies.js +0 -52
- package/dist/migrate/legacy/workflow-migrations-frozen.js +0 -21
- package/dist/migrate/legacy-ref-grammar.js +0 -214
- package/dist/output/shapes/distill.js +0 -14
- package/dist/output/shapes/history.js +0 -11
- package/dist/output/text/distill.js +0 -6
- package/dist/output/text/enable-disable.js +0 -8
- package/dist/output/text/history.js +0 -6
- package/dist/registry/build-index.js +0 -382
- package/dist/schemas/akm-config.json +0 -4704
- package/dist/schemas/akm-task.json +0 -87
- package/dist/schemas/akm-workflow.json +0 -372
- package/dist/scripts/migrate-storage.js +0 -3816
- package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
- package/dist/workflows/cli.js +0 -53
- package/dist/workflows/exec/brief.js +0 -481
- package/dist/workflows/exec/report.js +0 -1460
- package/dist/workflows/exec/watch.js +0 -116
- package/dist/workflows/program/parser.js +0 -813
- package/dist/workflows/program/project.js +0 -104
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import { lookup as dnsLookup } from "node:dns/promises";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
5
|
import fs from "node:fs";
|
|
7
|
-
import { isIP } from "node:net";
|
|
8
6
|
import path from "node:path";
|
|
9
|
-
import { fetchWithRetry, ResponseTooLargeError, readBodyWithByteCap, resolveStashDir } from "../../core/common.js";
|
|
7
|
+
import { fetchWithRetry, isWithin, ResponseTooLargeError, readBodyWithByteCap, resolveStashDir, todayIso, } from "../../core/common.js";
|
|
10
8
|
import { ConfigError, UsageError } from "../../core/errors.js";
|
|
11
9
|
import { getRegistryIndexCacheDir } from "../../core/paths.js";
|
|
12
|
-
import { warn } from "../../core/warn.js";
|
|
10
|
+
import { warn, warnVerbose } from "../../core/warn.js";
|
|
13
11
|
import { withFreshnessCache } from "../freshness.js";
|
|
14
12
|
import { sanitizeString } from "../providers/provider-utils.js";
|
|
13
|
+
import { htmlToMarkdownAndLinks } from "./content-extract.js";
|
|
14
|
+
import { escapeMarkdownStructure } from "./fetcher-util.js";
|
|
15
|
+
import { assertResolvedHostAllowed, assertWebsiteRequestUrl, isLoopbackWebsiteHostname, } from "./host-guard.js";
|
|
15
16
|
import { loadWikiSnapshotFetchers } from "./registry.js";
|
|
17
|
+
import { createAllowAllRobotsPolicy, createRobotsPolicy, isPathAllowedByRobots, ROBOTS_BODY_TIMEOUT_MS, ROBOTS_BYTE_CAP, } from "./robots.js";
|
|
16
18
|
/** Refresh website snapshots every 12 hours to balance freshness with scraping load. */
|
|
17
19
|
const CACHE_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
18
20
|
/** Allow up to 7 days of stale snapshots when refresh fails so search remains available during outages. */
|
|
@@ -35,6 +37,32 @@ const WEBSITE_PAGE_BYTE_CAP = 5 * 1024 * 1024;
|
|
|
35
37
|
*/
|
|
36
38
|
const WEBSITE_CRAWL_WALL_CLOCK_MS = 10 * 60 * 1000;
|
|
37
39
|
const WEBSITE_MAX_REDIRECTS = 8;
|
|
40
|
+
/**
|
|
41
|
+
* Coerces the user-facing `crawlTimeoutMs` option.
|
|
42
|
+
*
|
|
43
|
+
* Returns `null` for an explicit opt-out (`false`, or `0`), the configured
|
|
44
|
+
* number of milliseconds when positive, and `undefined` to mean "unset, use
|
|
45
|
+
* the default". Anything else is ignored rather than failing a crawl over a
|
|
46
|
+
* malformed knob.
|
|
47
|
+
*/
|
|
48
|
+
function coerceCrawlTimeoutMs(value) {
|
|
49
|
+
if (value === false || value === 0)
|
|
50
|
+
return null;
|
|
51
|
+
if (value === true || value === undefined || value === null)
|
|
52
|
+
return undefined;
|
|
53
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
|
|
54
|
+
if (!Number.isFinite(parsed))
|
|
55
|
+
return undefined;
|
|
56
|
+
if (parsed <= 0)
|
|
57
|
+
return null;
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* How many times a URL may be pushed back for not fitting its origin's
|
|
62
|
+
* `Crawl-delay` in the remaining budget before it is reported unfetched.
|
|
63
|
+
* Bounds the requeue loop when every remaining URL is rate-limited.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_CRAWL_DEFERRALS = 3;
|
|
38
66
|
/**
|
|
39
67
|
* Body-read deadline for a single page (30s). The per-request fetch timeout
|
|
40
68
|
* (15s) bounds only the connection/header phase; without this a server that
|
|
@@ -59,7 +87,7 @@ function resolveFetcherStashDir(explicitStashDir) {
|
|
|
59
87
|
if (explicitStashDir)
|
|
60
88
|
return explicitStashDir;
|
|
61
89
|
try {
|
|
62
|
-
return resolveStashDir(
|
|
90
|
+
return resolveStashDir();
|
|
63
91
|
}
|
|
64
92
|
catch {
|
|
65
93
|
return null;
|
|
@@ -88,9 +116,19 @@ export async function ensureWebsiteMirror(config, options) {
|
|
|
88
116
|
refresh: async () => {
|
|
89
117
|
fs.mkdirSync(cachePaths.rootDir, { recursive: true });
|
|
90
118
|
await scrapeWebsiteToStash(normalizedUrl, cachePaths.stashDir, {
|
|
119
|
+
fetcherStashDir: resolveFetcherStashDir(),
|
|
91
120
|
maxPages: coercePositiveInt(config.options?.maxPages, MAX_PAGES_DEFAULT),
|
|
92
121
|
maxDepth: coercePositiveInt(config.options?.maxDepth, MAX_DEPTH_DEFAULT),
|
|
122
|
+
respectRobots: coerceRespectRobots(config.options?.respectRobots),
|
|
93
123
|
allowPrivateHosts: options?.allowPrivateHosts,
|
|
124
|
+
wallClockCapMs: options?.wallClockCapMs,
|
|
125
|
+
crawlTimeoutMs: coerceCrawlTimeoutMs(config.options?.crawlTimeoutMs),
|
|
126
|
+
resolveSecret: options?.resolveSecret,
|
|
127
|
+
// As-supplied, pre-normalization start URL (see crawlWebsite's
|
|
128
|
+
// `rawStartUrl` doc comment): threaded through purely for the C-02
|
|
129
|
+
// robots.txt check, which must see the trailing slash the user
|
|
130
|
+
// actually typed before `normalizeSiteUrl` strips it.
|
|
131
|
+
rawStartUrl: rawUrl,
|
|
94
132
|
});
|
|
95
133
|
fs.writeFileSync(cachePaths.manifestPath, `${JSON.stringify({ url: normalizedUrl, fetchedAt: new Date().toISOString() }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
96
134
|
},
|
|
@@ -102,13 +140,16 @@ function hasExtractedSite(stashDir) {
|
|
|
102
140
|
const knowledgeDir = path.join(stashDir, "knowledge");
|
|
103
141
|
if (!fs.statSync(stashDir).isDirectory() || !fs.statSync(knowledgeDir).isDirectory())
|
|
104
142
|
return false;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
143
|
+
const pending = [knowledgeDir];
|
|
144
|
+
while (pending.length > 0) {
|
|
145
|
+
const dir = pending.pop();
|
|
146
|
+
if (!dir)
|
|
147
|
+
break;
|
|
148
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
149
|
+
if (entry.isFile() && entry.name.endsWith(".md"))
|
|
111
150
|
return true;
|
|
151
|
+
if (entry.isDirectory())
|
|
152
|
+
pending.push(path.join(dir, entry.name));
|
|
112
153
|
}
|
|
113
154
|
}
|
|
114
155
|
return false;
|
|
@@ -117,24 +158,196 @@ function hasExtractedSite(stashDir) {
|
|
|
117
158
|
return false;
|
|
118
159
|
}
|
|
119
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Iterate the snapshot-fetcher registry against a parsed URL, returning the
|
|
163
|
+
* first fetcher that produces content, or null when none match. A fetcher that
|
|
164
|
+
* throws is logged and treated as a non-match — one broken fetcher must not
|
|
165
|
+
* fail the whole source.
|
|
166
|
+
*/
|
|
167
|
+
async function dispatchSnapshotFetchers(parsed, context, stashDir) {
|
|
168
|
+
for (const fetcher of await loadWikiSnapshotFetchers(stashDir)) {
|
|
169
|
+
try {
|
|
170
|
+
if (!fetcher.matches(parsed, context))
|
|
171
|
+
continue;
|
|
172
|
+
const snapshot = await fetcher.fetch(parsed, context);
|
|
173
|
+
if (snapshot)
|
|
174
|
+
return snapshot;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (context.signal?.aborted)
|
|
178
|
+
throw error;
|
|
179
|
+
warn("[akm] snapshot fetcher %s threw on %s: %s", fetcher.name, parsed.toString(), error instanceof Error ? error.message : String(error));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Run the snapshot-fetcher registry against a URL. Returns null when no
|
|
186
|
+
* fetcher matches or produces content, so the caller falls back to a crawl.
|
|
187
|
+
*/
|
|
188
|
+
async function fetchSnapshotViaRegistry(startUrl, stashDir, allowPrivateHosts, resolveSecret) {
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = new URL(startUrl);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const context = {
|
|
197
|
+
stashDir: stashDir ?? "",
|
|
198
|
+
timeoutMs: 15_000,
|
|
199
|
+
...(resolveSecret ? { resolveSecret } : {}),
|
|
200
|
+
...(allowPrivateHosts ? { allowPrivateHosts: true } : {}),
|
|
201
|
+
};
|
|
202
|
+
return dispatchSnapshotFetchers(parsed, context, stashDir);
|
|
203
|
+
}
|
|
204
|
+
let snapshotWriteHookForTests;
|
|
205
|
+
/**
|
|
206
|
+
* TEST-ONLY. Interrupt a refresh partway through its page-write loop;
|
|
207
|
+
* `undefined` restores. Exists because "a process killed mid-refresh" cannot
|
|
208
|
+
* be staged from outside the module — the whole loop is a single synchronous
|
|
209
|
+
* burst between two awaits. Inert in production (one `undefined?.()` per page).
|
|
210
|
+
*/
|
|
211
|
+
export function _setWebsiteSnapshotWriteHookForTests(hook) {
|
|
212
|
+
snapshotWriteHookForTests = hook;
|
|
213
|
+
}
|
|
214
|
+
function snapshotWriteHook(event) {
|
|
215
|
+
snapshotWriteHookForTests?.(event);
|
|
216
|
+
}
|
|
217
|
+
function snapshotSiblingPrefix(stashDir, kind) {
|
|
218
|
+
return `.${path.basename(stashDir)}.${kind}-`;
|
|
219
|
+
}
|
|
220
|
+
function snapshotSiblingPath(stashDir, kind) {
|
|
221
|
+
const unique = `${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
222
|
+
return path.join(path.dirname(stashDir), `${snapshotSiblingPrefix(stashDir, kind)}${unique}`);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Age gate for the staging sweep. Nothing enforces one refresh at a time for a
|
|
226
|
+
* given website source, so a sibling directory may belong to a refresh that is
|
|
227
|
+
* still running in another process; deleting it would break a healthy run
|
|
228
|
+
* instead of cleaning up after a dead one. Only clearly-abandoned directories
|
|
229
|
+
* (untouched for an hour — far longer than the 10-minute crawl wall-clock cap)
|
|
230
|
+
* are swept. Leftovers are inert until then: they are dot-prefixed, so the
|
|
231
|
+
* indexer's walk skips them.
|
|
232
|
+
*/
|
|
233
|
+
const SNAPSHOT_STAGING_SWEEP_AGE_MS = 60 * 60 * 1000;
|
|
234
|
+
/** Remove staging/retired directories abandoned by an earlier interrupted run. */
|
|
235
|
+
function sweepSnapshotStaging(stashDir) {
|
|
236
|
+
const parent = path.dirname(stashDir);
|
|
237
|
+
let entries;
|
|
238
|
+
try {
|
|
239
|
+
entries = fs.readdirSync(parent);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const prefixes = [snapshotSiblingPrefix(stashDir, "staging"), snapshotSiblingPrefix(stashDir, "retired")];
|
|
245
|
+
const cutoff = Date.now() - SNAPSHOT_STAGING_SWEEP_AGE_MS;
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (!prefixes.some((prefix) => entry.startsWith(prefix)))
|
|
248
|
+
continue;
|
|
249
|
+
const abandoned = path.join(parent, entry);
|
|
250
|
+
try {
|
|
251
|
+
if (fs.statSync(abandoned).mtimeMs > cutoff)
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function beginSnapshotStaging(stashDir) {
|
|
261
|
+
fs.mkdirSync(path.dirname(stashDir), { recursive: true });
|
|
262
|
+
sweepSnapshotStaging(stashDir);
|
|
263
|
+
const dir = snapshotSiblingPath(stashDir, "staging");
|
|
264
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
265
|
+
return { dir, target: stashDir };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Swap the staged snapshot in. POSIX cannot atomically exchange two non-empty
|
|
269
|
+
* directories, so the previous snapshot is renamed ASIDE first and deleted
|
|
270
|
+
* afterwards: the window in which the target does not exist is one syscall
|
|
271
|
+
* wide instead of an entire write loop.
|
|
272
|
+
*/
|
|
273
|
+
function publishSnapshotStaging(staging) {
|
|
274
|
+
let retired;
|
|
275
|
+
if (fs.existsSync(staging.target)) {
|
|
276
|
+
retired = snapshotSiblingPath(staging.target, "retired");
|
|
277
|
+
fs.renameSync(staging.target, retired);
|
|
278
|
+
}
|
|
279
|
+
fs.renameSync(staging.dir, staging.target);
|
|
280
|
+
if (retired)
|
|
281
|
+
fs.rmSync(retired, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
/** Drop an unpublished staging directory (no-op once it has been renamed). */
|
|
284
|
+
function discardSnapshotStaging(staging) {
|
|
285
|
+
fs.rmSync(staging.dir, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
/** Materialize a single fetcher snapshot as the source's whole stash. */
|
|
288
|
+
function writeSnapshotToStash(stashDir, snapshot) {
|
|
289
|
+
const preferredName = snapshot.preferredName ?? deriveImportPath(snapshot.url);
|
|
290
|
+
const relPath = avoidReservedBasename(preferredName);
|
|
291
|
+
// Validate against the FINAL location so the guarantee (and the error text)
|
|
292
|
+
// is independent of where the file is staged.
|
|
293
|
+
const knowledgeDir = path.join(stashDir, "knowledge");
|
|
294
|
+
if (!isWithin(path.resolve(knowledgeDir, `${relPath}.md`), knowledgeDir)) {
|
|
295
|
+
throw new UsageError(`Snapshot fetcher returned an unsafe preferred name: ${JSON.stringify(preferredName)}`);
|
|
296
|
+
}
|
|
297
|
+
const staging = beginSnapshotStaging(stashDir);
|
|
298
|
+
try {
|
|
299
|
+
const filePath = path.resolve(path.join(staging.dir, "knowledge"), `${relPath}.md`);
|
|
300
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
301
|
+
const slug = relPath.split("/").pop() ?? "index";
|
|
302
|
+
fs.writeFileSync(filePath, buildMarkdownSnapshot({ url: snapshot.url, title: snapshot.title, markdown: snapshot.markdown }, slug, snapshot.tags), "utf8");
|
|
303
|
+
snapshotWriteHook({ point: "page-written", index: 1, total: 1, relPath: `knowledge/${relPath}.md` });
|
|
304
|
+
publishSnapshotStaging(staging);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
discardSnapshotStaging(staging);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
120
310
|
async function scrapeWebsiteToStash(startUrl, stashDir, options) {
|
|
311
|
+
// Offer the URL to the specialized fetchers before falling back to a crawl.
|
|
312
|
+
// Without this, `akm bundle add <feed|profile URL>` reaches only the generic
|
|
313
|
+
// crawler and the RSS/Bluesky/X/YouTube fetchers are unreachable outside the
|
|
314
|
+
// `akm import` path. A fetcher returning null falls through to the crawl.
|
|
315
|
+
const fetched = await fetchSnapshotViaRegistry(startUrl, options.fetcherStashDir ?? null, options.allowPrivateHosts, options.resolveSecret);
|
|
316
|
+
if (fetched) {
|
|
317
|
+
writeSnapshotToStash(stashDir, fetched);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
121
320
|
const pages = await crawlWebsite(startUrl, options);
|
|
122
321
|
if (pages.length === 0) {
|
|
123
322
|
throw new Error(`No content could be scraped from ${startUrl}`);
|
|
124
323
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
324
|
+
const staging = beginSnapshotStaging(stashDir);
|
|
325
|
+
try {
|
|
326
|
+
const knowledgeDir = path.join(staging.dir, "knowledge");
|
|
327
|
+
fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
328
|
+
const usedPaths = new Set();
|
|
329
|
+
let written = 0;
|
|
330
|
+
for (const page of pages) {
|
|
331
|
+
const relPath = avoidReservedBasename(urlToRelativePath(page.url));
|
|
332
|
+
const uniquePath = uniqueSlug(relPath, usedPaths);
|
|
333
|
+
const filePath = path.join(knowledgeDir, `${uniquePath}.md`);
|
|
334
|
+
const dir = path.dirname(filePath);
|
|
335
|
+
if (dir !== knowledgeDir)
|
|
336
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
337
|
+
const slug = uniquePath.split("/").pop() ?? "index";
|
|
338
|
+
fs.writeFileSync(filePath, buildMarkdownSnapshot(page, slug), "utf8");
|
|
339
|
+
written++;
|
|
340
|
+
snapshotWriteHook({
|
|
341
|
+
point: "page-written",
|
|
342
|
+
index: written,
|
|
343
|
+
total: pages.length,
|
|
344
|
+
relPath: `knowledge/${uniquePath}.md`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
publishSnapshotStaging(staging);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
discardSnapshotStaging(staging);
|
|
138
351
|
}
|
|
139
352
|
}
|
|
140
353
|
export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
|
|
@@ -145,24 +358,37 @@ export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
|
|
|
145
358
|
stashDir: stashDir ?? "",
|
|
146
359
|
timeoutMs: options?.timeoutMs ?? 15_000,
|
|
147
360
|
signal: options?.signal,
|
|
361
|
+
...(options?.resolveSecret ? { resolveSecret: options.resolveSecret } : {}),
|
|
362
|
+
...(options?.allowPrivateHosts ? { allowPrivateHosts: true } : {}),
|
|
148
363
|
};
|
|
149
|
-
|
|
364
|
+
const snapshot = await dispatchSnapshotFetchers(parsedUrl, context, stashDir);
|
|
365
|
+
if (snapshot)
|
|
366
|
+
return websiteMarkdownSnapshotFromResult(snapshot);
|
|
367
|
+
const fetchedResponse = await fetchWebsiteResponse(normalizedUrl, 0, {
|
|
368
|
+
allowPrivateHosts: options?.allowPrivateHosts,
|
|
369
|
+
signal: options?.signal,
|
|
370
|
+
});
|
|
371
|
+
const finalUrl = normalizeCrawlUrl(fetchedResponse.finalUrl) ?? normalizedUrl;
|
|
372
|
+
if (finalUrl !== normalizedUrl) {
|
|
373
|
+
let redirectedSnapshot;
|
|
150
374
|
try {
|
|
151
|
-
|
|
152
|
-
continue;
|
|
153
|
-
const snapshot = await fetcher.fetch(parsedUrl, context);
|
|
154
|
-
if (!snapshot)
|
|
155
|
-
continue;
|
|
156
|
-
return websiteMarkdownSnapshotFromResult(snapshot);
|
|
375
|
+
redirectedSnapshot = await dispatchSnapshotFetchers(new URL(finalUrl), context, stashDir);
|
|
157
376
|
}
|
|
158
377
|
catch (error) {
|
|
159
|
-
|
|
378
|
+
await fetchedResponse.response.body?.cancel().catch(() => undefined);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
if (redirectedSnapshot) {
|
|
382
|
+
await fetchedResponse.response.body?.cancel().catch(() => undefined);
|
|
383
|
+
return websiteMarkdownSnapshotFromResult(redirectedSnapshot);
|
|
160
384
|
}
|
|
161
385
|
}
|
|
162
|
-
const fetched = await
|
|
163
|
-
|
|
386
|
+
const fetched = await websitePageFromResponse(fetchedResponse, normalizedUrl, {
|
|
387
|
+
allowPrivateHosts: options?.allowPrivateHosts,
|
|
388
|
+
signal: options?.signal,
|
|
389
|
+
});
|
|
390
|
+
if (!fetched)
|
|
164
391
|
throw new UsageError(`No content could be fetched from ${normalizedUrl}`);
|
|
165
|
-
}
|
|
166
392
|
return websiteMarkdownSnapshotFromResult({
|
|
167
393
|
url: fetched.page.url,
|
|
168
394
|
title: fetched.page.title,
|
|
@@ -184,13 +410,168 @@ function websiteMarkdownSnapshotFromResult(snapshot) {
|
|
|
184
410
|
}, slug || "website", snapshot.tags),
|
|
185
411
|
};
|
|
186
412
|
}
|
|
413
|
+
function sleep(ms) {
|
|
414
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Evaluates a URL against robots.txt, checking both `normalizedUrl` — the
|
|
418
|
+
* form akm treats as canonical for dedup/storage, with any bare trailing
|
|
419
|
+
* slash already stripped by `normalizeCrawlUrl`/`normalizeSiteUrl` — and,
|
|
420
|
+
* when it differs, `rawUrl`: the URL exactly as discovered (a link's literal
|
|
421
|
+
* `href`), as-supplied (the user's typed start URL), or redirected-to (a
|
|
422
|
+
* `Location` header), before any such stripping. Delegates the actual
|
|
423
|
+
* allow/disallow decision to {@link decideRobotsAllowance}'s asymmetric
|
|
424
|
+
* matrix — see its doc comment — rather than a plain AND of both forms: a
|
|
425
|
+
* `Disallow: /dir/`-shaped rule needs the un-stripped `rawUrl` to ever match
|
|
426
|
+
* (closing that gap), while an `Allow: /docs/`-shaped rule needs it too, in
|
|
427
|
+
* the *other* direction (a normalized-disallowed-but-raw-allowed URL must
|
|
428
|
+
* still be treated as allowed here, not just at the point where akm chooses
|
|
429
|
+
* which literal URL to fetch — a URL reaching this function has already been
|
|
430
|
+
* fetched, under whichever form `crawlWebsite`/`resolveCrawlRobotsDecision`
|
|
431
|
+
* selected, so only the `allowed` verdict matters here, never `fetchUrl`).
|
|
432
|
+
*
|
|
433
|
+
* `normalizeCrawlUrl`/`normalizeSiteUrl` strip a bare trailing slash before
|
|
434
|
+
* any robots check ever runs, so a `Disallow: /dir/`-shaped rule — which
|
|
435
|
+
* requires a literal trailing `/` in the target, see
|
|
436
|
+
* `matchesCompiledPattern`'s prefix check — can never match the stripped
|
|
437
|
+
* alias. Checking the un-stripped `rawUrl` closes that gap without changing
|
|
438
|
+
* what akm treats as the canonical URL for storage/dedup, and (crucially)
|
|
439
|
+
* without over-blocking a URL that never had a trailing slash to begin with
|
|
440
|
+
* — e.g. a `/secret` link that happens to redirect to `/secret/`: the
|
|
441
|
+
* pre-redirect request itself is unaffected by a `Disallow: /secret/` rule,
|
|
442
|
+
* only the redirect target is.
|
|
443
|
+
*/
|
|
444
|
+
async function isCrawlUrlAllowedByRobots(robots, normalizedUrl, rawUrl) {
|
|
445
|
+
return (await resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl)).allowed;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Decides whether `normalizedUrl` (akm's canonical form — dedup/cache key,
|
|
449
|
+
* with any bare trailing slash already stripped by `normalizeCrawlUrl`/
|
|
450
|
+
* `normalizeSiteUrl`) may be crawled, and which literal URL to actually
|
|
451
|
+
* request.
|
|
452
|
+
*
|
|
453
|
+
* A `Disallow: /dir/`-shaped rule requires a literal trailing `/` in the
|
|
454
|
+
* target (see `matchesCompiledPattern`'s prefix check), so it can never match
|
|
455
|
+
* the slash-stripped normalized alias — checking the un-stripped `rawUrl` (a
|
|
456
|
+
* link's literal `href`, the user's as-typed start URL, or a redirect
|
|
457
|
+
* `Location`) closes that gap. Symmetrically, an `Allow: /docs/`-shaped rule
|
|
458
|
+
* requires that same trailing `/` to match, so a start URL or link typed as
|
|
459
|
+
* `.../docs/` under `Disallow: / \n Allow: /docs/` is allowed in its raw form
|
|
460
|
+
* but disallowed once normalized — over-blocking a site the owner explicitly
|
|
461
|
+
* opened to crawlers.
|
|
462
|
+
*
|
|
463
|
+
* Resolution matrix (raw form only consulted when it differs from normalized):
|
|
464
|
+
* - normalized allowed, raw allowed (or no distinct raw) => allowed, fetch normalized
|
|
465
|
+
* - normalized allowed, raw disallowed => BLOCKED (raw wins: closes the Disallow: /dir/ gap)
|
|
466
|
+
* - normalized disallowed, raw allowed => allowed, fetch the RAW url (closes the Allow: /docs/ gap)
|
|
467
|
+
* - normalized disallowed, raw disallowed => BLOCKED
|
|
468
|
+
*
|
|
469
|
+
* Only the third row switches the fetch target; every other row fetches the
|
|
470
|
+
* normalized form akm already treats as canonical. Do not collapse this to a
|
|
471
|
+
* plain OR of the two checks — that would also flip the second row to
|
|
472
|
+
* "allowed" and reopen the `Disallow: /dir/` bypass this matrix exists to
|
|
473
|
+
* close.
|
|
474
|
+
*/
|
|
475
|
+
function decideRobotsAllowance(rules, normalizedUrl, rawUrl) {
|
|
476
|
+
const normalizedAllowed = isPathAllowedByRobots(rules, normalizedUrl);
|
|
477
|
+
if (!rawUrl || rawUrl === normalizedUrl) {
|
|
478
|
+
return { allowed: normalizedAllowed, fetchUrl: normalizedUrl };
|
|
479
|
+
}
|
|
480
|
+
const rawAllowed = isPathAllowedByRobots(rules, rawUrl);
|
|
481
|
+
if (!normalizedAllowed && rawAllowed) {
|
|
482
|
+
return { allowed: true, fetchUrl: rawUrl };
|
|
483
|
+
}
|
|
484
|
+
return { allowed: normalizedAllowed && rawAllowed, fetchUrl: normalizedUrl };
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Async wrapper of {@link decideRobotsAllowance} for call sites holding a
|
|
488
|
+
* `RobotsPolicy` (which resolves/caches rules per origin) rather than an
|
|
489
|
+
* already-fetched `RobotsRuleSet`.
|
|
490
|
+
*/
|
|
491
|
+
async function resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl) {
|
|
492
|
+
const rules = await robots.rulesFor(normalizedUrl);
|
|
493
|
+
return decideRobotsAllowance(rules, normalizedUrl, rawUrl);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* C-02/C-03: fail fast, before any page fetch, when the start URL itself is
|
|
497
|
+
* off-limits. A 5xx robots.txt (RobotsPolicy caches `DISALLOW_ALL_RULES` for
|
|
498
|
+
* that case) gets a distinct message calling out the server error, per spec
|
|
499
|
+
* §4.6.
|
|
500
|
+
*
|
|
501
|
+
* Checks both `start`'s (normalized) URL and `rawStartUrl` — the URL exactly
|
|
502
|
+
* as the user supplied it in config, before `validateWebsiteUrl` ->
|
|
503
|
+
* `normalizeSiteUrl` stripped any trailing slash — via
|
|
504
|
+
* `decideRobotsAllowance`. Without this, a start URL typed as `.../secret/`
|
|
505
|
+
* under `Disallow: /secret/` would never match that rule and would be
|
|
506
|
+
* crawled instead of rejected with the spec §4.6 C-02 UsageError; conversely,
|
|
507
|
+
* a start URL typed as `.../docs/` under `Disallow: / \n Allow: /docs/`
|
|
508
|
+
* would be normalized to `.../docs`, fail to match `Allow: /docs/`, and be
|
|
509
|
+
* rejected even though the site owner explicitly opened `/docs/` to
|
|
510
|
+
* crawlers. `crawlWebsite`'s queue gate applies the same decision (and, in
|
|
511
|
+
* the Allow case, actually fetches the raw URL this function only validates
|
|
512
|
+
* against) — see its call to `resolveCrawlRobotsDecision`.
|
|
513
|
+
*/
|
|
514
|
+
async function assertStartUrlAllowedByRobots(robots, start, rawStartUrl) {
|
|
515
|
+
const startUrl = start.toString();
|
|
516
|
+
const robotsUrl = new URL("/robots.txt", start.origin).toString();
|
|
517
|
+
const rules = await robots.rulesFor(startUrl);
|
|
518
|
+
if (rules.disallowAll) {
|
|
519
|
+
throw new UsageError(`Refusing to crawl ${startUrl}: ${robotsUrl} returned a server error, which robots.txt conventions ` +
|
|
520
|
+
`treat as a full disallow until it recovers. Set respectRobots: false on this website source to bypass ` +
|
|
521
|
+
`robots.txt.`);
|
|
522
|
+
}
|
|
523
|
+
let rawStartUrlNormalized;
|
|
524
|
+
if (rawStartUrl) {
|
|
525
|
+
try {
|
|
526
|
+
rawStartUrlNormalized = new URL(rawStartUrl).toString();
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
rawStartUrlNormalized = undefined;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const { allowed } = decideRobotsAllowance(rules, startUrl, rawStartUrlNormalized);
|
|
533
|
+
if (!allowed) {
|
|
534
|
+
throw new UsageError(`Refusing to crawl ${startUrl}: disallowed by ${robotsUrl}. Set respectRobots: false on this website ` +
|
|
535
|
+
`source to bypass robots.txt.`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
187
538
|
async function crawlWebsite(startUrl, options) {
|
|
188
539
|
const start = new URL(normalizeSiteUrl(startUrl));
|
|
189
540
|
const allowedOrigin = start.origin;
|
|
190
|
-
const queue = [
|
|
541
|
+
const queue = [
|
|
542
|
+
{ url: start.toString(), rawUrl: options.rawStartUrl ?? start.toString(), depth: 0, deferrals: 0 },
|
|
543
|
+
];
|
|
191
544
|
const visited = new Set();
|
|
192
545
|
const pages = [];
|
|
193
|
-
|
|
546
|
+
// Precedence: the test-only seam, then the user's `crawlTimeoutMs`, then the
|
|
547
|
+
// default. `crawlTimeoutMs: 0` / `false` disables the cap outright, for a
|
|
548
|
+
// deliberately long-running crawl the user is willing to babysit.
|
|
549
|
+
const configuredCapMs = options.crawlTimeoutMs === null ? null : (options.crawlTimeoutMs ?? WEBSITE_CRAWL_WALL_CLOCK_MS);
|
|
550
|
+
const wallClockCapMs = options.wallClockCapMs ?? configuredCapMs;
|
|
551
|
+
const capDisabled = wallClockCapMs === null;
|
|
552
|
+
const deadline = capDisabled ? Number.POSITIVE_INFINITY : Date.now() + wallClockCapMs;
|
|
553
|
+
// Between-iteration deadline checks cannot interrupt work already in
|
|
554
|
+
// flight: a single request's `Retry-After` sleep, or a slow body read, can
|
|
555
|
+
// run far past the cap on its own. This signal makes the cap a HARD limit —
|
|
556
|
+
// it aborts the in-flight fetch and the retry sleep alike.
|
|
557
|
+
const abortController = new AbortController();
|
|
558
|
+
const capTimer = capDisabled
|
|
559
|
+
? undefined
|
|
560
|
+
: setTimeout(() => abortController.abort(new Error(`Website crawl exceeded its ${wallClockCapMs / 1000}s limit`)), wallClockCapMs);
|
|
561
|
+
const crawlSignal = abortController.signal;
|
|
562
|
+
const robots = options.respectRobots === false
|
|
563
|
+
? createAllowAllRobotsPolicy()
|
|
564
|
+
: createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts: options.allowPrivateHosts, signal: crawlSignal }));
|
|
565
|
+
await assertStartUrlAllowedByRobots(robots, start, options.rawStartUrl);
|
|
566
|
+
// Counts actual `fetchWebsitePage` invocations (regardless of outcome) so
|
|
567
|
+
// Crawl-delay pacing skips the first fetch and never charges a delay slot
|
|
568
|
+
// to a URL that robots.txt skipped without ever being fetched (C-11).
|
|
569
|
+
let fetchAttempts = 0;
|
|
570
|
+
// URLs pushed back because their Crawl-delay would not fit in the remaining
|
|
571
|
+
// budget, and URLs that ran out of retries entirely. Reported at the end so
|
|
572
|
+
// a rate-limited origin is visible rather than silently missing.
|
|
573
|
+
const deferred = new Set();
|
|
574
|
+
const unfetched = new Set();
|
|
194
575
|
while (queue.length > 0 && pages.length < options.maxPages) {
|
|
195
576
|
if (Date.now() > deadline)
|
|
196
577
|
break;
|
|
@@ -200,8 +581,49 @@ async function crawlWebsite(startUrl, options) {
|
|
|
200
581
|
const normalized = normalizeCrawlUrl(next.url);
|
|
201
582
|
if (!normalized || visited.has(normalized))
|
|
202
583
|
continue;
|
|
584
|
+
const decision = await resolveCrawlRobotsDecision(robots, normalized, next.rawUrl);
|
|
585
|
+
if (!decision.allowed) {
|
|
586
|
+
// Deliberately NOT marked visited. `/docs/` and `/docs` share a
|
|
587
|
+
// normalized key but get different robots verdicts (a `Disallow: /docs/`
|
|
588
|
+
// rule matches only the trailing-slash form). Marking the key visited
|
|
589
|
+
// here would let whichever alias happened to be discovered first — and
|
|
590
|
+
// was then rejected — permanently suppress the allowed alias, making
|
|
591
|
+
// crawl coverage depend on link order. Robots rules are cached per
|
|
592
|
+
// origin, so re-evaluating a repeated disallowed alias is cheap.
|
|
593
|
+
warnVerbose("[akm] website crawl: skipping %s (disallowed by robots.txt)", normalized);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (fetchAttempts > 0) {
|
|
597
|
+
const delayMs = await robots.crawlDelayMs(normalized);
|
|
598
|
+
if (delayMs > 0) {
|
|
599
|
+
if (Date.now() + delayMs >= deadline) {
|
|
600
|
+
// Sleeping this one out would blow the wall-clock cap. Defer it to
|
|
601
|
+
// the back of the queue instead of ending the crawl here: other
|
|
602
|
+
// origins may have no Crawl-delay and can still be fetched with the
|
|
603
|
+
// time that remains. Anything still deferred when the deadline
|
|
604
|
+
// arrives is reported as unfetched below rather than silently
|
|
605
|
+
// dropped. `deferred` is bounded so a queue of delayed URLs cannot
|
|
606
|
+
// spin forever re-appending to itself.
|
|
607
|
+
if (next.deferrals < MAX_CRAWL_DEFERRALS) {
|
|
608
|
+
queue.push({ ...next, deferrals: next.deferrals + 1 });
|
|
609
|
+
deferred.add(normalized);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
unfetched.add(normalized);
|
|
613
|
+
}
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
await sleep(delayMs);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
203
619
|
visited.add(normalized);
|
|
204
|
-
|
|
620
|
+
deferred.delete(normalized);
|
|
621
|
+
fetchAttempts++;
|
|
622
|
+
const fetched = await fetchWebsitePage(decision.fetchUrl, {
|
|
623
|
+
allowPrivateHosts: options.allowPrivateHosts,
|
|
624
|
+
robots,
|
|
625
|
+
signal: crawlSignal,
|
|
626
|
+
});
|
|
205
627
|
if (!fetched)
|
|
206
628
|
continue;
|
|
207
629
|
pages.push(fetched.page);
|
|
@@ -212,20 +634,59 @@ async function crawlWebsite(startUrl, options) {
|
|
|
212
634
|
break;
|
|
213
635
|
if (link.origin !== allowedOrigin)
|
|
214
636
|
continue;
|
|
215
|
-
const
|
|
637
|
+
const rawLinkUrl = link.toString();
|
|
638
|
+
const candidate = normalizeCrawlUrl(rawLinkUrl);
|
|
216
639
|
if (!candidate || visited.has(candidate) || isAssetLikePath(link.pathname))
|
|
217
640
|
continue;
|
|
218
|
-
queue.push({ url: candidate, depth: next.depth + 1 });
|
|
641
|
+
queue.push({ url: candidate, rawUrl: rawLinkUrl, depth: next.depth + 1, deferrals: 0 });
|
|
219
642
|
}
|
|
220
643
|
}
|
|
221
|
-
if (Date.now() > deadline) {
|
|
222
|
-
warn("[akm] website crawl stopped at the %ds wall-clock cap with %d/%d pages collected from %s."
|
|
644
|
+
if (!capDisabled && Date.now() > deadline) {
|
|
645
|
+
warn("[akm] website crawl stopped at the %ds wall-clock cap with %d/%d pages collected from %s. " +
|
|
646
|
+
"Raise crawlTimeoutMs on this website source, or set it to 0 to disable the cap.", wallClockCapMs / 1000, pages.length, options.maxPages, startUrl);
|
|
647
|
+
}
|
|
648
|
+
if (capTimer)
|
|
649
|
+
clearTimeout(capTimer);
|
|
650
|
+
// A URL still deferred when the loop ends never got fetched — report it
|
|
651
|
+
// rather than letting a rate-limited origin go missing without a trace.
|
|
652
|
+
for (const url of deferred)
|
|
653
|
+
unfetched.add(url);
|
|
654
|
+
if (unfetched.size > 0) {
|
|
655
|
+
warn("[akm] website crawl: %d URL(s) were not fetched — their origin's Crawl-delay did not fit in the " +
|
|
656
|
+
"remaining time budget. First: %s", unfetched.size, [...unfetched].slice(0, 3).join(", "));
|
|
223
657
|
}
|
|
224
658
|
return pages;
|
|
225
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Sentinel thrown by `fetchWebsiteResponse` when a redirect hop's target is
|
|
662
|
+
* disallowed by robots.txt (see the doc comment above the check in
|
|
663
|
+
* `fetchWebsiteResponse`). Never escapes `fetchWebsitePage`, which maps it to
|
|
664
|
+
* `null` — the same "page skipped, no error" outcome as any other
|
|
665
|
+
* robots-disallowed URL. Not exported; purely an internal control-flow
|
|
666
|
+
* signal between the two functions.
|
|
667
|
+
*/
|
|
668
|
+
class RobotsDisallowedRedirectError extends Error {
|
|
669
|
+
constructor(url) {
|
|
670
|
+
super(`robots.txt disallows redirect target ${url}`);
|
|
671
|
+
this.name = "RobotsDisallowedRedirectError";
|
|
672
|
+
}
|
|
673
|
+
}
|
|
226
674
|
async function fetchWebsitePage(pageUrl, options) {
|
|
227
|
-
|
|
675
|
+
let fetchedResponse;
|
|
676
|
+
try {
|
|
677
|
+
fetchedResponse = await fetchWebsiteResponse(pageUrl, 0, options);
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
if (err instanceof RobotsDisallowedRedirectError)
|
|
681
|
+
return null;
|
|
682
|
+
throw err;
|
|
683
|
+
}
|
|
684
|
+
return websitePageFromResponse(fetchedResponse, pageUrl, options);
|
|
685
|
+
}
|
|
686
|
+
async function websitePageFromResponse(fetched, pageUrl, options) {
|
|
687
|
+
const { response } = fetched;
|
|
228
688
|
if (!response.ok) {
|
|
689
|
+
await response.body?.cancel().catch(() => undefined);
|
|
229
690
|
if (response.status === 404)
|
|
230
691
|
return null;
|
|
231
692
|
throw new Error(`Failed to fetch website content (${response.status}) from ${pageUrl}`);
|
|
@@ -235,6 +696,7 @@ async function fetchWebsitePage(pageUrl, options) {
|
|
|
235
696
|
try {
|
|
236
697
|
body = await readBodyWithByteCap(response, WEBSITE_PAGE_BYTE_CAP, {
|
|
237
698
|
bodyTimeoutMs: WEBSITE_PAGE_BODY_TIMEOUT_MS,
|
|
699
|
+
signal: options?.signal,
|
|
238
700
|
});
|
|
239
701
|
}
|
|
240
702
|
catch (err) {
|
|
@@ -242,24 +704,40 @@ async function fetchWebsitePage(pageUrl, options) {
|
|
|
242
704
|
return null;
|
|
243
705
|
throw err;
|
|
244
706
|
}
|
|
245
|
-
const
|
|
707
|
+
const rawFinalUrl = fetched.finalUrl;
|
|
708
|
+
const finalUrl = normalizeCrawlUrl(rawFinalUrl) ?? pageUrl;
|
|
246
709
|
assertWebsiteRequestUrl(finalUrl, Error, options);
|
|
247
|
-
|
|
710
|
+
// Re-check robots.txt against the FINAL (post-redirect) URL, not just the
|
|
711
|
+
// pre-redirect URL crawlWebsite already gated. normalizeCrawlUrl strips
|
|
712
|
+
// trailing slashes before the initial gate, so a rule shaped like
|
|
713
|
+
// `Disallow: /secret/` correctly lets `/secret` through that gate; if the
|
|
714
|
+
// server then redirects to `/secret/` (a common trailing-slash
|
|
715
|
+
// canonicalization), the disallowed page would otherwise be fetched and
|
|
716
|
+
// stored without ever being weighed against robots.txt. See spec §4.6 C-04.
|
|
717
|
+
// Checks `rawFinalUrl` (the un-normalized `response.url`, slash intact) as
|
|
718
|
+
// well as `finalUrl`, per `isCrawlUrlAllowedByRobots` — `normalizeCrawlUrl`
|
|
719
|
+
// would otherwise strip the very trailing slash a `Disallow: /secret/`
|
|
720
|
+
// rule needs to match.
|
|
721
|
+
if (options?.robots && !(await isCrawlUrlAllowedByRobots(options.robots, finalUrl, rawFinalUrl))) {
|
|
722
|
+
warnVerbose("[akm] website crawl: skipping %s (disallowed by robots.txt after redirect)", finalUrl);
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
if (contentType.includes("text/html") ||
|
|
726
|
+
contentType.includes("application/xhtml+xml") ||
|
|
727
|
+
(!contentType && looksLikeMarkup(body))) {
|
|
248
728
|
const title = extractHtmlTitle(body) || new URL(finalUrl).hostname;
|
|
729
|
+
// One parse yields both the content Markdown and the whole-document links.
|
|
730
|
+
const { markdown, links } = htmlToMarkdownAndLinks(body, finalUrl);
|
|
249
731
|
return {
|
|
250
|
-
page: {
|
|
251
|
-
|
|
252
|
-
title,
|
|
253
|
-
markdown: htmlToMarkdown(body, finalUrl),
|
|
254
|
-
},
|
|
255
|
-
links: extractSameDocumentLinks(body, finalUrl),
|
|
732
|
+
page: { url: finalUrl, title, markdown },
|
|
733
|
+
links,
|
|
256
734
|
};
|
|
257
735
|
}
|
|
258
736
|
return {
|
|
259
737
|
page: {
|
|
260
738
|
url: finalUrl,
|
|
261
739
|
title: extractTextTitle(body) || new URL(finalUrl).hostname,
|
|
262
|
-
markdown: body
|
|
740
|
+
markdown: plainTextToMarkdown(body),
|
|
263
741
|
},
|
|
264
742
|
links: [],
|
|
265
743
|
};
|
|
@@ -277,25 +755,145 @@ async function fetchWebsiteResponse(pageUrl, redirectCount = 0, options) {
|
|
|
277
755
|
"User-Agent": "akm-cli website provider",
|
|
278
756
|
},
|
|
279
757
|
redirect: "manual",
|
|
758
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
280
759
|
}, { timeout: 15_000, retries: 1 });
|
|
281
760
|
if (response.status >= 300 && response.status < 400) {
|
|
282
761
|
if (redirectCount >= WEBSITE_MAX_REDIRECTS) {
|
|
762
|
+
await response.body?.cancel().catch(() => undefined);
|
|
283
763
|
throw new Error(`Too many redirects while fetching ${pageUrl}`);
|
|
284
764
|
}
|
|
285
765
|
const location = response.headers.get("location");
|
|
286
766
|
if (!location) {
|
|
767
|
+
await response.body?.cancel().catch(() => undefined);
|
|
287
768
|
throw new Error(`Redirect response from ${pageUrl} did not include a Location header`);
|
|
288
769
|
}
|
|
770
|
+
await response.body?.cancel().catch(() => undefined);
|
|
289
771
|
const nextUrl = new URL(location, pageUrl).toString();
|
|
290
|
-
|
|
772
|
+
try {
|
|
773
|
+
assertWebsiteRequestUrl(nextUrl, Error, options);
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
if (options?.robots) {
|
|
777
|
+
warnVerbose("[akm] website crawl: skipping unsafe redirect to %s", nextUrl);
|
|
778
|
+
throw new RobotsDisallowedRedirectError(nextUrl);
|
|
779
|
+
}
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
// Robots-check every intermediate redirect hop, not just the pre-redirect
|
|
783
|
+
// queue URL (`crawlWebsite`'s gate) and the FINAL URL (the post-redirect
|
|
784
|
+
// recheck below in `fetchWebsitePage`). Without this, a chain like
|
|
785
|
+
// `/go` -> 302 `/secret/` -> 302 `/public` issues a live GET to
|
|
786
|
+
// `/secret/` even when robots.txt disallows it, because that hop is
|
|
787
|
+
// never the queue URL and never the final URL. Only gated when a
|
|
788
|
+
// `RobotsPolicy` was actually threaded in (`crawlWebsite`); single-URL
|
|
789
|
+
// `fetchWebsiteMarkdownSnapshot` fetches stay deliberately ungated per
|
|
790
|
+
// spec §1.
|
|
791
|
+
if (options?.robots) {
|
|
792
|
+
const normalizedNext = normalizeCrawlUrl(nextUrl);
|
|
793
|
+
if (!normalizedNext) {
|
|
794
|
+
// `normalizeCrawlUrl` returns null for anything that isn't http(s) —
|
|
795
|
+
// a redirect `Location` can legally point at `mailto:`, `tel:`, a
|
|
796
|
+
// bare relative path that resolves to an opaque scheme, etc.
|
|
797
|
+
// `RobotsPolicy.rulesFor` computes `new URL(url).origin` and then
|
|
798
|
+
// resolves `/robots.txt` against it; for a non-http(s) URL that
|
|
799
|
+
// origin is the literal string "null", and re-resolving against it
|
|
800
|
+
// throws an unhandled TypeError that aborts the whole crawl. Refuse
|
|
801
|
+
// the hop outright instead of ever handing such a URL to the policy —
|
|
802
|
+
// `fetchWebsitePage` maps this to a graceful skip. Regression
|
|
803
|
+
// introduced by a67412c, which fell back to the raw `nextUrl` here.
|
|
804
|
+
warnVerbose("[akm] website crawl: skipping redirect to %s (not an http(s) URL)", nextUrl);
|
|
805
|
+
throw new RobotsDisallowedRedirectError(nextUrl);
|
|
806
|
+
}
|
|
807
|
+
if (!(await isCrawlUrlAllowedByRobots(options.robots, normalizedNext, nextUrl))) {
|
|
808
|
+
warnVerbose("[akm] website crawl: skipping redirect to %s (disallowed by robots.txt)", nextUrl);
|
|
809
|
+
throw new RobotsDisallowedRedirectError(nextUrl);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
291
812
|
return fetchWebsiteResponse(nextUrl, redirectCount + 1, options);
|
|
292
813
|
}
|
|
293
|
-
return response;
|
|
814
|
+
return { response, finalUrl: pageUrl };
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Fetches and classifies `<origin>/robots.txt`. Reuses `fetchWebsiteResponse`
|
|
818
|
+
* (spec §6.2: no second fetch path) so robots.txt gets the exact same SSRF
|
|
819
|
+
* guards, retry, and redirect handling as a page fetch.
|
|
820
|
+
*
|
|
821
|
+
* Steps 1–2 (the guard on the INITIAL URL) run OUTSIDE the try/catch on
|
|
822
|
+
* purpose: a guard rejection there must propagate as-is, never be downgraded
|
|
823
|
+
* to "unavailable" (spec §4.5 F-12, §6.2). Guard rejections on a LATER
|
|
824
|
+
* redirect hop happen inside `fetchWebsiteResponse`, which the try/catch
|
|
825
|
+
* below does cover — the guard has already refused to fetch that host, so
|
|
826
|
+
* only the error *reporting* is downgraded (F-11).
|
|
827
|
+
*/
|
|
828
|
+
export async function loadRobotsTxt(robotsUrl, options) {
|
|
829
|
+
assertWebsiteRequestUrl(robotsUrl, UsageError, options);
|
|
830
|
+
await assertResolvedHostAllowed(new URL(robotsUrl).hostname, options);
|
|
831
|
+
try {
|
|
832
|
+
const { response } = await fetchWebsiteResponse(robotsUrl, 0, options);
|
|
833
|
+
if (response.status >= 200 && response.status < 300) {
|
|
834
|
+
try {
|
|
835
|
+
const text = await readBodyWithByteCap(response, ROBOTS_BYTE_CAP, {
|
|
836
|
+
bodyTimeoutMs: ROBOTS_BODY_TIMEOUT_MS,
|
|
837
|
+
signal: options?.signal,
|
|
838
|
+
});
|
|
839
|
+
return { kind: "body", text };
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
if (err instanceof ResponseTooLargeError) {
|
|
843
|
+
warn("[akm] robots.txt at %s exceeded the %d-byte cap; treating it as unavailable.", robotsUrl, ROBOTS_BYTE_CAP);
|
|
844
|
+
return { kind: "unavailable" };
|
|
845
|
+
}
|
|
846
|
+
throw err;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (response.status >= 500 && response.status < 600) {
|
|
850
|
+
await response.body?.cancel().catch(() => undefined);
|
|
851
|
+
// RFC 9309 §2.3.1.4: an unreachable robots.txt is a full disallow, not
|
|
852
|
+
// an allow-all. `fetchWithRetry` already retried this once, so a
|
|
853
|
+
// transient blip does not trip it.
|
|
854
|
+
warn("[akm] robots.txt at %s returned %d; treating the crawl as fully disallowed until it recovers. " +
|
|
855
|
+
"Set respectRobots: false on this website source to bypass robots.txt.", robotsUrl, response.status);
|
|
856
|
+
return { kind: "unreachable" };
|
|
857
|
+
}
|
|
858
|
+
// 4xx (404 is the common, silent case), and any other non-2xx/5xx status.
|
|
859
|
+
await response.body?.cancel().catch(() => undefined);
|
|
860
|
+
return { kind: "unavailable" };
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
if (options?.signal?.aborted)
|
|
864
|
+
throw err;
|
|
865
|
+
warnVerbose("[akm] failed to fetch robots.txt at %s: %s", robotsUrl, err instanceof Error ? err.message : String(err));
|
|
866
|
+
return { kind: "unavailable" };
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Coerces `SourceConfigEntry.options.respectRobots` to a boolean. The bundle
|
|
871
|
+
* descriptor is boolean-validated at config load (schema), but the legacy
|
|
872
|
+
* `sources[].options` bag is `z.record(z.unknown())` and accepts anything, so
|
|
873
|
+
* the runtime read still validates. A misspelled non-boolean opt-out fails
|
|
874
|
+
* loudly (`ConfigError`) rather than silently defaulting either way — the
|
|
875
|
+
* user would otherwise think robots.txt handling is something other than
|
|
876
|
+
* what akm is actually doing (spec §4.7).
|
|
877
|
+
*/
|
|
878
|
+
export function coerceRespectRobots(value) {
|
|
879
|
+
if (value === undefined || value === null)
|
|
880
|
+
return true;
|
|
881
|
+
if (typeof value === "boolean")
|
|
882
|
+
return value;
|
|
883
|
+
if (typeof value === "string") {
|
|
884
|
+
const normalized = value.trim().toLowerCase();
|
|
885
|
+
if (normalized === "true")
|
|
886
|
+
return true;
|
|
887
|
+
if (normalized === "false")
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
throw new ConfigError(`Invalid value for respectRobots: expected a boolean (or "true"/"false"), got ${JSON.stringify(value)}.`);
|
|
294
891
|
}
|
|
295
892
|
function buildMarkdownSnapshot(page, slug, tags) {
|
|
296
893
|
const title = sanitizeString(page.title, 200) || slug;
|
|
297
|
-
const
|
|
894
|
+
const heading = title.replace(/([\\[\]`*_])/g, "\\$1").replace(/<(?=[a-zA-Z/!?])/g, "<");
|
|
298
895
|
const host = sanitizeString(new URL(page.url).hostname, 120);
|
|
896
|
+
const description = sanitizeString(`Website snapshot from ${host}`, 500);
|
|
299
897
|
const content = page.markdown.trim() || `Source: ${page.url}`;
|
|
300
898
|
const normalizedTags = Array.from(new Set(["website", host, ...(tags ?? [])]));
|
|
301
899
|
return [
|
|
@@ -304,11 +902,14 @@ function buildMarkdownSnapshot(page, slug, tags) {
|
|
|
304
902
|
`description: ${JSON.stringify(description)}`,
|
|
305
903
|
`sourceUrl: ${JSON.stringify(page.url)}`,
|
|
306
904
|
`title: ${JSON.stringify(title)}`,
|
|
905
|
+
`updated: ${todayIso()}`,
|
|
906
|
+
"lint_skip:",
|
|
907
|
+
" - stale-path",
|
|
307
908
|
"tags:",
|
|
308
909
|
...normalizedTags.map((tag) => ` - ${JSON.stringify(tag)}`),
|
|
309
910
|
"---",
|
|
310
911
|
"",
|
|
311
|
-
`# ${
|
|
912
|
+
`# ${heading}`,
|
|
312
913
|
"",
|
|
313
914
|
`Source: ${page.url}`,
|
|
314
915
|
"",
|
|
@@ -436,92 +1037,16 @@ function coercePositiveInt(value, fallback) {
|
|
|
436
1037
|
function looksLikeMarkup(body) {
|
|
437
1038
|
return /<html[\s>]|<body[\s>]|<\/[a-z][\w:-]*>/i.test(body);
|
|
438
1039
|
}
|
|
439
|
-
function
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return decodeHtmlEntities(stripTags(h1)).trim();
|
|
446
|
-
return undefined;
|
|
447
|
-
}
|
|
448
|
-
function extractTextTitle(text) {
|
|
449
|
-
for (const line of text.split(/\r?\n/)) {
|
|
450
|
-
const trimmed = line.trim();
|
|
451
|
-
if (!trimmed)
|
|
452
|
-
continue;
|
|
453
|
-
if (trimmed.startsWith("#"))
|
|
454
|
-
return trimmed.replace(/^#+\s*/, "");
|
|
455
|
-
return trimmed.slice(0, 120);
|
|
456
|
-
}
|
|
457
|
-
return undefined;
|
|
1040
|
+
function plainTextToMarkdown(body) {
|
|
1041
|
+
const inlineSafe = body
|
|
1042
|
+
.replace(/\r\n?/g, "\n")
|
|
1043
|
+
.replace(/([\\[\]`])/g, "\\$1")
|
|
1044
|
+
.replace(/<(?=[a-zA-Z/!?])/g, "<");
|
|
1045
|
+
return escapeMarkdownStructure(inlineSafe).trim();
|
|
458
1046
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
for (const match of html.matchAll(hrefPattern)) {
|
|
463
|
-
const href = match[2]?.trim();
|
|
464
|
-
if (!href || href.startsWith("#"))
|
|
465
|
-
continue;
|
|
466
|
-
try {
|
|
467
|
-
const resolved = new URL(href, pageUrl);
|
|
468
|
-
if (!isSafeLinkUrl(resolved))
|
|
469
|
-
continue;
|
|
470
|
-
links.push(resolved);
|
|
471
|
-
}
|
|
472
|
-
catch {
|
|
473
|
-
/* ignore malformed links */
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
return links;
|
|
477
|
-
}
|
|
478
|
-
function htmlToMarkdown(html, pageUrl) {
|
|
479
|
-
let text = html;
|
|
480
|
-
text = stripDangerousBlockTag(text, "script");
|
|
481
|
-
text = stripDangerousBlockTag(text, "style");
|
|
482
|
-
text = stripDangerousBlockTag(text, "noscript");
|
|
483
|
-
text = stripDangerousBlockTag(text, "template");
|
|
484
|
-
text = text.replace(/<pre\b[^>]*><code\b[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_match, code) => {
|
|
485
|
-
const decoded = decodeHtmlEntities(stripTags(code)).trim();
|
|
486
|
-
return decoded ? `\n\n\`\`\`\n${decoded}\n\`\`\`\n\n` : "\n\n";
|
|
487
|
-
});
|
|
488
|
-
text = text.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_match, code) => {
|
|
489
|
-
const decoded = decodeHtmlEntities(stripTags(code)).trim();
|
|
490
|
-
return decoded ? `\`${decoded}\`` : "";
|
|
491
|
-
});
|
|
492
|
-
text = text.replace(/<a\b[^>]*href\s*=\s*(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (_match, _q, href, body) => {
|
|
493
|
-
const label = decodeHtmlEntities(stripTags(body)).trim();
|
|
494
|
-
if (!label)
|
|
495
|
-
return "";
|
|
496
|
-
try {
|
|
497
|
-
const resolved = new URL(href, pageUrl);
|
|
498
|
-
if (!isSafeLinkUrl(resolved))
|
|
499
|
-
return label;
|
|
500
|
-
return `[${label}](${resolved})`;
|
|
501
|
-
}
|
|
502
|
-
catch {
|
|
503
|
-
return label;
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
text = text.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_match, level, body) => {
|
|
507
|
-
const heading = decodeHtmlEntities(stripTags(body)).trim();
|
|
508
|
-
return heading ? `\n\n${"#".repeat(Number(level))} ${heading}\n\n` : "\n\n";
|
|
509
|
-
});
|
|
510
|
-
text = text.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, body) => {
|
|
511
|
-
const item = decodeHtmlEntities(stripTags(body)).trim();
|
|
512
|
-
return item ? `\n- ${item}` : "";
|
|
513
|
-
});
|
|
514
|
-
text = text.replace(/<(p|div|section|article|main|header|footer|blockquote|table|tr)\b[^>]*>/gi, "\n\n");
|
|
515
|
-
text = text.replace(/<\/(p|div|section|article|main|header|footer|blockquote|table|tr)>/gi, "\n\n");
|
|
516
|
-
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
517
|
-
text = text.replace(/<\/?(ul|ol)\b[^>]*>/gi, "\n");
|
|
518
|
-
text = decodeHtmlEntities(stripTags(text));
|
|
519
|
-
text = text
|
|
520
|
-
.replace(/\r/g, "")
|
|
521
|
-
.replace(/[ \t]+\n/g, "\n")
|
|
522
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
523
|
-
.trim();
|
|
524
|
-
return text;
|
|
1047
|
+
/** True for URL paths that are plainly binary assets, never crawlable pages. */
|
|
1048
|
+
function isAssetLikePath(pathname) {
|
|
1049
|
+
return /\.(css|js|json|png|jpe?g|gif|svg|ico|webp|pdf|zip|tar|gz|mp4|mp3|woff2?)$/i.test(pathname);
|
|
525
1050
|
}
|
|
526
1051
|
function stripTags(value) {
|
|
527
1052
|
return value.replace(/<[^>]+>/g, " ");
|
|
@@ -546,148 +1071,25 @@ function decodeHtmlEntities(value) {
|
|
|
546
1071
|
return namedEntities[normalized] ?? match;
|
|
547
1072
|
});
|
|
548
1073
|
}
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const hostname = parsedUrl.hostname.toLowerCase();
|
|
558
|
-
if (hostname.endsWith(".invalid")) {
|
|
559
|
-
throw new ErrorType(`Refusing to fetch reserved invalid hostname: ${parsedUrl.hostname}`);
|
|
560
|
-
}
|
|
561
|
-
if (isForbiddenWebsiteHostname(hostname, options)) {
|
|
562
|
-
throw new ErrorType(`Refusing to fetch non-public website host: ${parsedUrl.hostname}`);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
async function defaultResolveHostname(hostname) {
|
|
566
|
-
const records = await dnsLookup(hostname, { all: true });
|
|
567
|
-
return records.map((record) => record.address);
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* Resolve-then-validate SSRF guard against DNS rebinding / private-range
|
|
571
|
-
* bypasses. {@link assertWebsiteRequestUrl} only rejects IP-literal and
|
|
572
|
-
* well-known-name hosts; a hostname like `private-host.example.com` that
|
|
573
|
-
* resolves to `10.0.0.1` passes those checks and then fetch connects to the
|
|
574
|
-
* private address. Here we resolve EVERY A/AAAA record and validate each
|
|
575
|
-
* against the same forbidden-range rules, failing CLOSED on an empty answer or
|
|
576
|
-
* resolver error.
|
|
577
|
-
*
|
|
578
|
-
* TOCTOU residual (documented, not fully closable here): Bun's `fetch` exposes
|
|
579
|
-
* no custom `lookup`/agent hook, so we cannot pin the socket to the exact IP we
|
|
580
|
-
* validated — a hostile resolver could return a public IP to this lookup and a
|
|
581
|
-
* private IP microseconds later at connect time (classic rebinding). This still
|
|
582
|
-
* removes the TRIVIAL `hostname A 10.0.0.1` bypass, which is the strongest
|
|
583
|
-
* guarantee available without a pinned-connection fetch API. Re-run on every
|
|
584
|
-
* redirect hop (the crawler recurses through `fetchWebsiteResponse`).
|
|
585
|
-
*/
|
|
586
|
-
export async function assertResolvedHostAllowed(hostname, options) {
|
|
587
|
-
if (options?.allowPrivateHosts === true)
|
|
588
|
-
return;
|
|
589
|
-
const bare = stripIpv6Brackets(hostname.toLowerCase());
|
|
590
|
-
// IP-literal hosts are already fully validated by assertWebsiteRequestUrl's
|
|
591
|
-
// range checks; resolving them is a no-op (and dnsLookup would just echo it).
|
|
592
|
-
if (isIP(bare) !== 0)
|
|
593
|
-
return;
|
|
594
|
-
const resolve = options?.resolveHostname ?? defaultResolveHostname;
|
|
595
|
-
let addresses;
|
|
596
|
-
try {
|
|
597
|
-
addresses = await resolve(bare);
|
|
598
|
-
}
|
|
599
|
-
catch {
|
|
600
|
-
throw new Error(`Refusing to fetch ${hostname}: DNS resolution failed`);
|
|
601
|
-
}
|
|
602
|
-
if (addresses.length === 0) {
|
|
603
|
-
throw new Error(`Refusing to fetch ${hostname}: hostname resolved to no addresses`);
|
|
604
|
-
}
|
|
605
|
-
for (const address of addresses) {
|
|
606
|
-
const version = isIP(address);
|
|
607
|
-
const forbidden = version === 4 ? isForbiddenIpv4(address) : version === 6 ? isForbiddenIpv6(stripIpv6Brackets(address)) : true;
|
|
608
|
-
if (forbidden) {
|
|
609
|
-
throw new Error(`Refusing to fetch ${hostname}: resolves to non-public or unparseable address ${address}`);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
// WHATWG URL.hostname wraps IPv6 literals in brackets (e.g. "[::1]"), but
|
|
614
|
-
// node:net's isIP() only recognizes the bare address form and returns 0 for
|
|
615
|
-
// anything bracketed — silently skipping all IPv6 forbidden-host checks
|
|
616
|
-
// below for every hostname parsed off a URL. Strip the brackets before any
|
|
617
|
-
// isIP()/isForbiddenIpv6() call so those checks actually run.
|
|
618
|
-
function stripIpv6Brackets(hostname) {
|
|
619
|
-
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1074
|
+
function extractHtmlTitle(html) {
|
|
1075
|
+
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
|
1076
|
+
if (title)
|
|
1077
|
+
return decodeHtmlEntities(stripTags(title)).trim();
|
|
1078
|
+
const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1];
|
|
1079
|
+
if (h1)
|
|
1080
|
+
return decodeHtmlEntities(stripTags(h1)).trim();
|
|
1081
|
+
return undefined;
|
|
620
1082
|
}
|
|
621
|
-
function
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1083
|
+
function extractTextTitle(text) {
|
|
1084
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1085
|
+
const trimmed = line.trim();
|
|
1086
|
+
if (!trimmed)
|
|
1087
|
+
continue;
|
|
1088
|
+
if (trimmed.startsWith("#"))
|
|
1089
|
+
return trimmed.replace(/^#+\s*/, "");
|
|
1090
|
+
return trimmed.slice(0, 120);
|
|
626
1091
|
}
|
|
627
|
-
|
|
628
|
-
const ipVersion = isIP(bareHostname);
|
|
629
|
-
if (ipVersion === 4)
|
|
630
|
-
return isForbiddenIpv4(bareHostname);
|
|
631
|
-
if (ipVersion === 6)
|
|
632
|
-
return isForbiddenIpv6(bareHostname);
|
|
633
|
-
return false;
|
|
634
|
-
}
|
|
635
|
-
function isLoopbackWebsiteHostname(hostname) {
|
|
636
|
-
if (hostname === "localhost" || hostname.endsWith(".localhost"))
|
|
637
|
-
return true;
|
|
638
|
-
const bareHostname = stripIpv6Brackets(hostname);
|
|
639
|
-
const ipVersion = isIP(bareHostname);
|
|
640
|
-
if (ipVersion === 4)
|
|
641
|
-
return bareHostname.startsWith("127.");
|
|
642
|
-
if (ipVersion === 6)
|
|
643
|
-
return bareHostname === "::1";
|
|
644
|
-
return false;
|
|
645
|
-
}
|
|
646
|
-
function isForbiddenIpv4(hostname) {
|
|
647
|
-
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
|
648
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
|
649
|
-
return true;
|
|
650
|
-
const a = parts[0];
|
|
651
|
-
const b = parts[1];
|
|
652
|
-
return (a === 0 ||
|
|
653
|
-
a === 10 ||
|
|
654
|
-
a === 127 ||
|
|
655
|
-
(a === 169 && b === 254) ||
|
|
656
|
-
(a === 172 && b >= 16 && b <= 31) ||
|
|
657
|
-
(a === 192 && b === 168));
|
|
658
|
-
}
|
|
659
|
-
/**
|
|
660
|
-
* Extracts the embedded IPv4 address from an IPv4-mapped IPv6 literal
|
|
661
|
-
* (`::ffff:a.b.c.d` or its canonical hex form `::ffff:xxxx:yyyy`), or
|
|
662
|
-
* returns null if `hostname` isn't one.
|
|
663
|
-
*/
|
|
664
|
-
function extractIpv4MappedAddress(normalizedHostname) {
|
|
665
|
-
const match = normalizedHostname.match(/^::ffff:(?:(\d{1,3}(?:\.\d{1,3}){3})|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/);
|
|
666
|
-
if (!match)
|
|
667
|
-
return null;
|
|
668
|
-
if (match[1])
|
|
669
|
-
return match[1];
|
|
670
|
-
const high = Number.parseInt(match[2], 16);
|
|
671
|
-
const low = Number.parseInt(match[3], 16);
|
|
672
|
-
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
|
|
673
|
-
}
|
|
674
|
-
function isForbiddenIpv6(hostname) {
|
|
675
|
-
const normalized = hostname.toLowerCase();
|
|
676
|
-
const mappedIpv4 = extractIpv4MappedAddress(normalized);
|
|
677
|
-
if (mappedIpv4)
|
|
678
|
-
return isForbiddenIpv4(mappedIpv4);
|
|
679
|
-
return (normalized === "::" ||
|
|
680
|
-
normalized === "::1" ||
|
|
681
|
-
normalized.startsWith("fc") ||
|
|
682
|
-
normalized.startsWith("fd") ||
|
|
683
|
-
normalized.startsWith("fe8") ||
|
|
684
|
-
normalized.startsWith("fe9") ||
|
|
685
|
-
normalized.startsWith("fea") ||
|
|
686
|
-
normalized.startsWith("feb"));
|
|
687
|
-
}
|
|
688
|
-
function stripDangerousBlockTag(value, tagName) {
|
|
689
|
-
const pattern = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}\\s*>`, "gi");
|
|
690
|
-
return value.replace(pattern, "");
|
|
1092
|
+
return undefined;
|
|
691
1093
|
}
|
|
692
1094
|
function safeCodePointToString(value) {
|
|
693
1095
|
if (!Number.isFinite(value) || value < 0 || value > 0x10ffff)
|
|
@@ -699,3 +1101,5 @@ function safeCodePointToString(value) {
|
|
|
699
1101
|
return undefined;
|
|
700
1102
|
}
|
|
701
1103
|
}
|
|
1104
|
+
// Re-exported for existing importers (the SSRF suite pins these entry points).
|
|
1105
|
+
export { assertResolvedHostAllowed } from "./host-guard.js";
|