@abot-ai/runtime 1.1.0 → 1.2.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.
- package/README.md +6 -3
- package/dist/src/capabilities/tool-types.d.ts +1 -1
- package/dist/src/model-gateway/providers/ollama/generation-options.d.ts +1 -3
- package/dist/src/model-gateway/providers/ollama/generation-options.js +1 -5
- package/dist/src/model-gateway/providers/ollama/payload.js +0 -8
- package/dist/src/model-gateway/server/request-body.js +1 -0
- package/dist/src/plugin-sdk/index.d.ts +2 -0
- package/dist/src/plugin-sdk/index.js +2 -0
- package/dist/src/plugin-sdk/tool-availability-brief.d.ts +9 -0
- package/dist/src/plugin-sdk/tool-availability-brief.js +48 -0
- package/dist/src/plugin-sdk/tool-availability-overview.d.ts +9 -0
- package/dist/src/plugin-sdk/tool-availability-overview.js +62 -0
- package/dist/src/runtime/adapters/registered-tool-payload-plan.d.ts +0 -9
- package/dist/src/runtime/adapters/registered-tool-payload-plan.js +0 -67
- package/dist/src/runtime/adapters/registered-tool-payload-related-artifacts.d.ts +13 -0
- package/dist/src/runtime/adapters/registered-tool-payload-related-artifacts.js +84 -0
- package/dist/src/runtime/adapters/registered-tool-worker-capabilities/catalog.js +4 -0
- package/dist/src/runtime/adapters/registered-tool-worker-capabilities/execution.js +3 -0
- package/dist/src/runtime/adapters/registered-tool-worker-capabilities/payload-lifecycle.d.ts +1 -0
- package/dist/src/runtime/adapters/registered-tool-worker-capabilities/payload-lifecycle.js +11 -1
- package/dist/src/runtime/adapters/registered-tool-worker-capabilities/payload-stages.d.ts +1 -0
- package/dist/src/runtime/adapters/registered-tool-worker-capabilities/payload-stages.js +9 -1
- package/dist/src/runtime/capabilities/request-bound-tool-registry.js +1 -24
- package/dist/src/runtime/capabilities/tool-availability.d.ts +6 -0
- package/dist/src/runtime/capabilities/tool-availability.js +28 -0
- package/dist/src/runtime/context/capability-brief.d.ts +25 -0
- package/dist/src/runtime/context/capability-brief.js +106 -0
- package/dist/src/runtime/context/request-tool-results-message-binding.d.ts +6 -0
- package/dist/src/runtime/context/request-tool-results-message-binding.js +30 -0
- package/dist/src/runtime/context/request-tool-results-scope.d.ts +3 -0
- package/dist/src/runtime/context/request-tool-results-scope.js +8 -0
- package/dist/src/runtime/context/request-tool-results.d.ts +8 -4
- package/dist/src/runtime/context/request-tool-results.js +49 -38
- package/dist/src/runtime/orchestration/role-calls/candidate-validation.js +8 -1
- package/dist/src/runtime/orchestration/role-calls/child-return.d.ts +4 -0
- package/dist/src/runtime/orchestration/role-calls/child-return.js +25 -5
- package/dist/src/runtime/orchestration/role-calls/command-decoder.js +8 -3
- package/dist/src/runtime/orchestration/role-calls/command-dispatch/child-transitions.d.ts +1 -1
- package/dist/src/runtime/orchestration/role-calls/command-dispatch/child-transitions.js +17 -2
- package/dist/src/runtime/orchestration/role-calls/command-dispatch.d.ts +1 -1
- package/dist/src/runtime/orchestration/role-calls/command-dispatch.js +2 -2
- package/dist/src/runtime/orchestration/role-calls/contracts.d.ts +5 -2
- package/dist/src/runtime/orchestration/role-calls/contracts.js +4 -4
- package/dist/src/runtime/orchestration/role-calls/dependency-results.d.ts +1 -1
- package/dist/src/runtime/orchestration/role-calls/dependency-results.js +1 -0
- package/dist/src/runtime/orchestration/role-calls/index.d.ts +5 -1
- package/dist/src/runtime/orchestration/role-calls/index.js +5 -1
- package/dist/src/runtime/orchestration/role-calls/ledger.d.ts +3 -1
- package/dist/src/runtime/orchestration/role-calls/ledger.js +9 -2
- package/dist/src/runtime/orchestration/role-calls/reducer.d.ts +1 -1
- package/dist/src/runtime/orchestration/role-calls/reducer.js +2 -2
- package/dist/src/runtime/orchestration/role-calls/result-receipt.d.ts +49 -0
- package/dist/src/runtime/orchestration/role-calls/result-receipt.js +188 -0
- package/dist/src/runtime/orchestration/role-calls/return-result-receipt.d.ts +19 -0
- package/dist/src/runtime/orchestration/role-calls/return-result-receipt.js +52 -0
- package/dist/src/runtime/orchestration/role-calls/transactions.js +1 -0
- package/dist/src/runtime/orchestration/role-calls/work-result-lineage.d.ts +34 -0
- package/dist/src/runtime/orchestration/role-calls/work-result-lineage.js +185 -0
- package/dist/src/runtime/orchestration/role-calls/work-result-receipt.d.ts +36 -0
- package/dist/src/runtime/orchestration/role-calls/work-result-receipt.js +187 -0
- package/dist/src/runtime/orchestration/role-executors/activation/result-normalization.js +6 -3
- package/dist/src/runtime/orchestration/role-executors/child-invocation/transaction.js +1 -0
- package/dist/src/runtime/orchestration/role-executors/contracts.d.ts +2 -1
- package/dist/src/runtime/orchestration/worker-capabilities/assignment-provenance.d.ts +36 -0
- package/dist/src/runtime/orchestration/worker-capabilities/assignment-provenance.js +171 -0
- package/dist/src/runtime/orchestration/worker-capabilities/contracts.d.ts +32 -1
- package/dist/src/runtime/orchestration/worker-capabilities/execution/invocation-preparation.d.ts +2 -1
- package/dist/src/runtime/orchestration/worker-capabilities/execution/invocation-preparation.js +3 -0
- package/dist/src/runtime/orchestration/worker-capabilities/execution.js +42 -19
- package/dist/src/runtime/orchestration/worker-capabilities/index.d.ts +2 -0
- package/dist/src/runtime/orchestration/worker-capabilities/index.js +2 -0
- package/dist/src/runtime/orchestration/worker-capabilities/payload-assignment-validation.d.ts +3 -0
- package/dist/src/runtime/orchestration/worker-capabilities/payload-assignment-validation.js +30 -0
- package/dist/src/runtime/orchestration/worker-capabilities/payload-author.js +8 -19
- package/dist/src/runtime/orchestration/worker-capabilities/payload-contracts.d.ts +5 -1
- package/dist/src/runtime/orchestration/worker-capabilities/payload-instructions.js +1 -1
- package/dist/src/runtime/orchestration/worker-capabilities/payload-source-provenance.d.ts +14 -0
- package/dist/src/runtime/orchestration/worker-capabilities/payload-source-provenance.js +132 -0
- package/dist/src/runtime/request/capability-brief.d.ts +5 -0
- package/dist/src/runtime/request/capability-brief.js +24 -0
- package/dist/src/runtime/request/execution-scope.d.ts +2 -1
- package/dist/src/runtime/request/role-executor-composition.js +1 -1
- package/dist/src/runtime/request/worker-capability-composition.d.ts +2 -1
- package/dist/src/runtime/request/worker-capability-composition.js +6 -1
- package/dist/src/runtime/request/worker-capability-payload.js +11 -8
- package/dist/src/runtime/steps/execution-agent/prompt.js +4 -3
- package/dist/src/runtime/steps/planner-decision/capability-catalog-context.d.ts +14 -0
- package/dist/src/runtime/steps/planner-decision/capability-catalog-context.js +87 -0
- package/dist/src/runtime/steps/planner-decision/contracts.d.ts +3 -5
- package/dist/src/runtime/steps/planner-decision/contracts.js +3 -3
- package/dist/src/runtime/steps/planner-decision/format.d.ts +3 -2
- package/dist/src/runtime/steps/planner-decision/format.js +73 -76
- package/dist/src/runtime/steps/planner-decision/input.d.ts +4 -3
- package/dist/src/runtime/steps/planner-decision/input.js +10 -7
- package/dist/src/runtime/steps/planner-decision/parser.d.ts +3 -2
- package/dist/src/runtime/steps/planner-decision/parser.js +10 -19
- package/dist/src/runtime/steps/planner-decision/plan-parser.js +18 -12
- package/dist/src/runtime/steps/planner-decision/prompt.js +33 -28
- package/dist/src/runtime/steps/planner-decision/resume.d.ts +1 -1
- package/dist/src/runtime/steps/planner-decision/resume.js +38 -1
- package/dist/src/runtime/steps/planner-decision/run.d.ts +3 -2
- package/dist/src/runtime/steps/reviewer-decision/audit-coverage.d.ts +10 -0
- package/dist/src/runtime/steps/reviewer-decision/audit-coverage.js +186 -0
- package/dist/src/runtime/steps/reviewer-decision/contracts.d.ts +3 -8
- package/dist/src/runtime/steps/reviewer-decision/contracts.js +12 -6
- package/dist/src/runtime/steps/reviewer-decision/delegated-context.d.ts +24 -0
- package/dist/src/runtime/steps/reviewer-decision/delegated-context.js +70 -0
- package/dist/src/runtime/steps/reviewer-decision/diagnostics.js +1 -1
- package/dist/src/runtime/steps/reviewer-decision/format.js +37 -24
- package/dist/src/runtime/steps/reviewer-decision/input.js +1 -1
- package/dist/src/runtime/steps/reviewer-decision/model-context.d.ts +4 -3
- package/dist/src/runtime/steps/reviewer-decision/model-context.js +92 -10
- package/dist/src/runtime/steps/reviewer-decision/parser.js +19 -1
- package/dist/src/runtime/steps/reviewer-decision/projection.js +42 -68
- package/dist/src/runtime/steps/reviewer-decision/prompt.js +10 -5
- package/dist/src/runtime/steps/reviewer-decision/run.js +12 -3
- package/dist/src/runtime/steps/reviewer-decision/verification-coverage.d.ts +14 -0
- package/dist/src/runtime/steps/reviewer-decision/verification-coverage.js +52 -0
- package/dist/src/runtime/steps/supervisor-decision/capability-brief.d.ts +14 -0
- package/dist/src/runtime/steps/supervisor-decision/capability-brief.js +38 -0
- package/dist/src/runtime/steps/supervisor-decision/contracts.d.ts +7 -3
- package/dist/src/runtime/steps/supervisor-decision/diagnostics.d.ts +2 -0
- package/dist/src/runtime/steps/supervisor-decision/diagnostics.js +8 -2
- package/dist/src/runtime/steps/supervisor-decision/format.js +32 -20
- package/dist/src/runtime/steps/supervisor-decision/input.d.ts +4 -1
- package/dist/src/runtime/steps/supervisor-decision/input.js +19 -1
- package/dist/src/runtime/steps/supervisor-decision/parser.js +62 -59
- package/dist/src/runtime/steps/supervisor-decision/prompt.js +6 -7
- package/dist/src/runtime/steps/supervisor-decision/resume.js +47 -1
- package/dist/src/runtime/steps/supervisor-decision/run.js +10 -0
- package/dist/src/runtime/steps/supervisor-decision/working-directory.js +2 -1
- package/dist/src/runtime/steps/worker-decision/contracts.d.ts +2 -1
- package/dist/src/runtime/steps/worker-decision/input/canonical-state.js +8 -1
- package/dist/src/runtime/steps/worker-decision/input/diagnostic-projection.js +2 -2
- package/dist/src/runtime/steps/worker-decision/input/prompt-context.d.ts +1 -0
- package/dist/src/runtime/steps/worker-decision/input/prompt-context.js +4 -1
- package/dist/src/runtime/steps/worker-decision/input/reference-context.js +34 -14
- package/dist/src/runtime/steps/worker-decision/input/types.d.ts +2 -1
- package/dist/src/runtime/steps/worker-decision/input.d.ts +1 -0
- package/dist/src/runtime/steps/worker-decision/input.js +1 -0
- package/dist/src/runtime/steps/worker-decision/payload-dependency-results.d.ts +8 -0
- package/dist/src/runtime/steps/worker-decision/payload-dependency-results.js +19 -0
- package/dist/src/runtime/steps/worker-decision/prompt.js +8 -8
- package/dist/src/runtime/steps/worker-decision/result-author.js +18 -11
- package/dist/src/runtime/steps/worker-decision/run.js +2 -2
- package/dist/src/web-ui/app/ARCHITECTURE.md +19 -6
- package/dist/src/web-ui/app/app.js +6 -7
- package/dist/src/web-ui/app/components/composer-context-window.d.ts +25 -0
- package/dist/src/web-ui/app/components/composer-context-window.js +155 -0
- package/dist/src/web-ui/app/components/composer-plan.d.ts +9 -0
- package/dist/src/web-ui/app/components/composer-plan.js +128 -0
- package/dist/src/web-ui/app/components/conversation-activity.d.ts +5 -6
- package/dist/src/web-ui/app/components/conversation-activity.js +64 -157
- package/dist/src/web-ui/app/components/conversation-role-cards.d.ts +23 -0
- package/dist/src/web-ui/app/components/conversation-role-cards.js +181 -0
- package/dist/src/web-ui/app/components/conversation-sidebar-layout.js +12 -0
- package/dist/src/web-ui/app/components/conversation-sources.d.ts +7 -0
- package/dist/src/web-ui/app/components/conversation-sources.js +182 -0
- package/dist/src/web-ui/app/components/conversation-view.js +34 -6
- package/dist/src/web-ui/app/components/model-selector.js +4 -1
- package/dist/src/web-ui/app/components/operations-section.js +46 -0
- package/dist/src/web-ui/app/components/workspace-shell.js +86 -78
- package/dist/src/web-ui/app/controllers/realtime-event-controller.d.ts +1 -0
- package/dist/src/web-ui/app/controllers/realtime-event-controller.js +55 -90
- package/dist/src/web-ui/app/controllers/runtime-selection-controller.js +2 -0
- package/dist/src/web-ui/app/controllers/session-controller.js +17 -12
- package/dist/src/web-ui/app/index.html +209 -210
- package/dist/src/web-ui/app/lib/composer-plan-model.d.ts +32 -0
- package/dist/src/web-ui/app/lib/composer-plan-model.js +108 -0
- package/dist/src/web-ui/app/lib/conversation-role-model.d.ts +19 -0
- package/dist/src/web-ui/app/lib/conversation-role-model.js +193 -0
- package/dist/src/web-ui/app/lib/event-presentation.js +30 -9
- package/dist/src/web-ui/app/lib/source-url-policy.d.ts +2 -0
- package/dist/src/web-ui/app/lib/source-url-policy.js +31 -0
- package/dist/src/web-ui/app/lib/task-progress.d.ts +53 -0
- package/dist/src/web-ui/app/lib/task-progress.js +233 -0
- package/dist/src/web-ui/app/lib/tool-invocation-count.d.ts +5 -0
- package/dist/src/web-ui/app/lib/tool-invocation-count.js +21 -0
- package/dist/src/web-ui/app/lib/web-source-event.d.ts +8 -0
- package/dist/src/web-ui/app/lib/web-source-event.js +21 -0
- package/dist/src/web-ui/app/lib/web-sources.d.ts +36 -0
- package/dist/src/web-ui/app/lib/web-sources.js +177 -0
- package/dist/src/web-ui/app/styles/00-tokens-base.css +15 -3
- package/dist/src/web-ui/app/styles/10-shell-sessions.css +24 -34
- package/dist/src/web-ui/app/styles/12-conversation-sidebar.css +93 -0
- package/dist/src/web-ui/app/styles/20-chat-composer.css +55 -472
- package/dist/src/web-ui/app/styles/21-composer-controls.css +368 -0
- package/dist/src/web-ui/app/styles/22-composer-context-window.css +90 -0
- package/dist/src/web-ui/app/styles/23-composer-plan.css +171 -0
- package/dist/src/web-ui/app/styles/24-conversation-role-cards.css +205 -0
- package/dist/src/web-ui/app/styles/25-conversation-sources.css +141 -0
- package/dist/src/web-ui/app/styles/30-inspector-config.css +21 -209
- package/dist/src/web-ui/app/styles/35-memory-onboarding.css +8 -8
- package/dist/src/web-ui/app/styles/36-memory-management.css +13 -13
- package/dist/src/web-ui/app/styles/37-config-operations.css +197 -0
- package/dist/src/web-ui/app/styles/40-config-workspace.css +36 -36
- package/dist/src/web-ui/app/styles/90-responsive.css +14 -77
- package/dist/src/web-ui/app/styles/foundation.css +2 -2
- package/dist/src/web-ui/app/styles.css +7 -0
- package/dist/src/web-ui/app/ui-behavior.d.ts +1 -1
- package/dist/src/web-ui/app/ui-behavior.js +1 -1
- package/docs/configuration.md +15 -8
- package/docs/installation.md +7 -4
- package/docs/known-limitations.md +21 -0
- package/docs/plugins.md +28 -13
- package/docs/runtime-invariant-coverage.md +18 -17
- package/docs/runtime-prompt-policy-contracts.md +27 -17
- package/package.json +4 -3
- package/plugins/capability-brief/source/index.ts +5 -48
- package/plugins/capability-brief/src/index.cjs +46 -33
- package/plugins/system-probe/plugin.json +1 -0
- package/plugins/web/README.md +125 -0
- package/plugins/web/plugin.json +1 -1
- package/plugins/web/skills/web_fetch_skill/SKILL.md +2 -2
- package/plugins/web/skills/web_search_skill/SKILL.md +8 -4
- package/plugins/web/source/errors.ts +5 -1
- package/plugins/web/source/fetch-presentation.ts +61 -0
- package/plugins/web/source/fetch-service.ts +2 -31
- package/plugins/web/source/light/config.ts +196 -0
- package/plugins/web/source/light/crawl/origin-gate.ts +134 -0
- package/plugins/web/source/light/crawl/robots-cache.ts +71 -0
- package/plugins/web/source/light/crawl/robots-parser.ts +132 -0
- package/plugins/web/source/light/crawl/robots-policy.ts +154 -0
- package/plugins/web/source/light/crawl/session-budget.ts +141 -0
- package/plugins/web/source/light/crawl/transport.ts +216 -0
- package/plugins/web/source/light/discovery/candidate-values.ts +63 -0
- package/plugins/web/source/light/discovery/feed-document.ts +127 -0
- package/plugins/web/source/light/discovery/html-links.ts +80 -0
- package/plugins/web/source/light/discovery/html-robots-directives.ts +29 -0
- package/plugins/web/source/light/discovery/markup-tree.ts +145 -0
- package/plugins/web/source/light/discovery/origin-dates.ts +71 -0
- package/plugins/web/source/light/discovery/sitemap-document.ts +63 -0
- package/plugins/web/source/light/documents/cache-policy.ts +28 -0
- package/plugins/web/source/light/documents/document-cache.ts +99 -0
- package/plugins/web/source/light/documents/document-charset.ts +89 -0
- package/plugins/web/source/light/documents/document-contract.ts +20 -0
- package/plugins/web/source/light/documents/document-reader.ts +75 -0
- package/plugins/web/source/light/documents/extract-document.ts +124 -0
- package/plugins/web/source/light/documents/http-cache-freshness.ts +68 -0
- package/plugins/web/source/light/documents/response-decoding.ts +120 -0
- package/plugins/web/source/light/ranking/document-ranking.ts +114 -0
- package/plugins/web/source/light/ranking/query-normalization.ts +69 -0
- package/plugins/web/source/light/search-collection.ts +136 -0
- package/plugins/web/source/light/search-frontier.ts +246 -0
- package/plugins/web/source/light/search-metadata.ts +84 -0
- package/plugins/web/source/light/search-service.ts +154 -0
- package/plugins/web/source/light/search-unavailable.ts +33 -0
- package/plugins/web/source/light/sources/catalog.ts +312 -0
- package/plugins/web/source/light/sources/source-definition.ts +9 -0
- package/plugins/web/source/light/sources/source-selection.ts +35 -0
- package/plugins/web/source/plugin.ts +70 -56
- package/plugins/web/source/public-http.ts +10 -1
- package/plugins/web/source/search-presentation.ts +63 -63
- package/plugins/web/source/search-service-selector.ts +31 -0
- package/plugins/web/source/search-service.ts +6 -0
- package/plugins/web/source/search-source-presentation.ts +76 -0
- package/plugins/web/source/search-types.ts +4 -0
- package/plugins/web/source/source-output-receipts.ts +267 -0
- package/plugins/web/source/source-receipt-budget.ts +51 -0
- package/plugins/web/source/source-receipt-contract.ts +18 -0
- package/plugins/web/src/index.cjs +3214 -288
- package/src/runtime/README.md +30 -9
- package/dist/src/model-gateway/structured-output/budget.d.ts +0 -7
- package/dist/src/model-gateway/structured-output/budget.js +0 -20
- package/dist/src/model-gateway/structured-output/schema-character-estimator.d.ts +0 -1
- package/dist/src/model-gateway/structured-output/schema-character-estimator.js +0 -211
- package/dist/src/model-gateway/structured-output-budget.d.ts +0 -1
- package/dist/src/model-gateway/structured-output-budget.js +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { WebPluginError } from "../../errors.js";
|
|
2
|
+
import type { LightConfig } from "../config.js";
|
|
3
|
+
|
|
4
|
+
export type LightStopReason = "time_budget" | "request_budget" | "byte_budget";
|
|
5
|
+
export type LightCrawlSnapshot = Readonly<{
|
|
6
|
+
requests: number;
|
|
7
|
+
bytes: number;
|
|
8
|
+
decodedBytes: number;
|
|
9
|
+
stopReason?: LightStopReason;
|
|
10
|
+
}>;
|
|
11
|
+
|
|
12
|
+
export function createSessionBudget(
|
|
13
|
+
config: LightConfig,
|
|
14
|
+
externalSignal?: AbortSignal,
|
|
15
|
+
) {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
const hardDeadline = startedAt + config.hardTimeoutMs;
|
|
19
|
+
let requests = 0;
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
let reservedBytes = 0;
|
|
22
|
+
let decodedBytes = 0;
|
|
23
|
+
let stopReason: LightStopReason | undefined;
|
|
24
|
+
let hardExpired = false;
|
|
25
|
+
const onAbort = () => controller.abort();
|
|
26
|
+
const hasReachedSoftDeadline = () =>
|
|
27
|
+
Date.now() >= startedAt + config.softTimeoutMs;
|
|
28
|
+
if (externalSignal?.aborted) controller.abort();
|
|
29
|
+
else externalSignal?.addEventListener("abort", onAbort, { once: true });
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
hardExpired = true;
|
|
32
|
+
stopReason ??= "time_budget";
|
|
33
|
+
controller.abort();
|
|
34
|
+
}, config.hardTimeoutMs);
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
|
|
37
|
+
const assertActive = () => {
|
|
38
|
+
if (externalSignal?.aborted) {
|
|
39
|
+
throw new WebPluginError(
|
|
40
|
+
"web_request_aborted",
|
|
41
|
+
"The web request was aborted.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (hardExpired || Date.now() >= hardDeadline) {
|
|
45
|
+
stopReason ??= "time_budget";
|
|
46
|
+
throw new WebPluginError(
|
|
47
|
+
"web_request_timed_out",
|
|
48
|
+
"The Light search exceeded its total time limit.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (controller.signal.aborted) {
|
|
52
|
+
throw new WebPluginError(
|
|
53
|
+
"web_request_aborted",
|
|
54
|
+
"The Light search session is closed.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const exhaust = (reason: LightStopReason): never => {
|
|
60
|
+
stopReason ??= reason;
|
|
61
|
+
throw new WebPluginError(
|
|
62
|
+
"web_search_budget_exhausted",
|
|
63
|
+
"The Light search reached its bounded crawl budget.",
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
hardDeadline,
|
|
70
|
+
assertActive,
|
|
71
|
+
canContinue() {
|
|
72
|
+
if (controller.signal.aborted) return false;
|
|
73
|
+
if (stopReason !== undefined) return false;
|
|
74
|
+
if (hasReachedSoftDeadline()) {
|
|
75
|
+
stopReason ??= "time_budget";
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (requests >= config.maxRequests) {
|
|
79
|
+
stopReason ??= "request_budget";
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
bytes >= config.maxTotalBytes ||
|
|
84
|
+
decodedBytes >= config.maxTotalBytes
|
|
85
|
+
) {
|
|
86
|
+
stopReason ??= "byte_budget";
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
reserveRequest() {
|
|
92
|
+
assertActive();
|
|
93
|
+
if (hasReachedSoftDeadline()) return exhaust("time_budget");
|
|
94
|
+
if (stopReason === "request_budget") return exhaust(stopReason);
|
|
95
|
+
if (stopReason === "byte_budget") return exhaust(stopReason);
|
|
96
|
+
if (requests >= config.maxRequests) return exhaust("request_budget");
|
|
97
|
+
if (
|
|
98
|
+
bytes + reservedBytes >= config.maxTotalBytes ||
|
|
99
|
+
decodedBytes >= config.maxTotalBytes
|
|
100
|
+
) {
|
|
101
|
+
return exhaust("byte_budget");
|
|
102
|
+
}
|
|
103
|
+
requests += 1;
|
|
104
|
+
const reservation = Math.min(
|
|
105
|
+
config.maxResponseBytes,
|
|
106
|
+
config.maxTotalBytes - bytes - reservedBytes,
|
|
107
|
+
);
|
|
108
|
+
reservedBytes += reservation;
|
|
109
|
+
return reservation;
|
|
110
|
+
},
|
|
111
|
+
completeRequest(count: number, reservation: number) {
|
|
112
|
+
reservedBytes -= reservation;
|
|
113
|
+
bytes += count;
|
|
114
|
+
if (bytes > config.maxTotalBytes) exhaust("byte_budget");
|
|
115
|
+
},
|
|
116
|
+
consumeDecodedBytes(count: number) {
|
|
117
|
+
if (!Number.isSafeInteger(count) || count < 0) {
|
|
118
|
+
throw new RangeError(
|
|
119
|
+
"Decoded byte count must be a non-negative safe integer.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (decodedBytes + count > config.maxTotalBytes) exhaust("byte_budget");
|
|
123
|
+
decodedBytes += count;
|
|
124
|
+
},
|
|
125
|
+
snapshot(): LightCrawlSnapshot {
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
requests,
|
|
128
|
+
bytes,
|
|
129
|
+
decodedBytes,
|
|
130
|
+
...(stopReason ? { stopReason } : {}),
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
dispose() {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
externalSignal?.removeEventListener("abort", onAbort);
|
|
136
|
+
controller.abort();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type LightSessionBudget = ReturnType<typeof createSessionBudget>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { WebPluginError } from "../../errors.js";
|
|
2
|
+
import { parsePublicHttpUrl } from "../../network-policy.js";
|
|
3
|
+
import { WEB_LIMITS } from "../../limits.js";
|
|
4
|
+
import type {
|
|
5
|
+
PublicHttpClient,
|
|
6
|
+
PublicHttpResponse,
|
|
7
|
+
} from "../../public-http.js";
|
|
8
|
+
import type { LightConfig } from "../config.js";
|
|
9
|
+
import { createOriginGate } from "./origin-gate.js";
|
|
10
|
+
import { createRobotsPolicy } from "./robots-policy.js";
|
|
11
|
+
import {
|
|
12
|
+
createSessionBudget,
|
|
13
|
+
type LightCrawlSnapshot,
|
|
14
|
+
} from "./session-budget.js";
|
|
15
|
+
|
|
16
|
+
export type LightCrawlSession = Readonly<{
|
|
17
|
+
get(url: string): Promise<PublicHttpResponse>;
|
|
18
|
+
canContinue(): boolean;
|
|
19
|
+
assertActive(): void;
|
|
20
|
+
consumeDecodedBytes(count: number): void;
|
|
21
|
+
snapshot(): LightCrawlSnapshot;
|
|
22
|
+
sitemapsFor(origin: string): readonly string[];
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
27
|
+
|
|
28
|
+
function parseLightUrl(raw: string): URL {
|
|
29
|
+
if (raw.length > WEB_LIMITS.upstreamUrlChars) {
|
|
30
|
+
throw new WebPluginError(
|
|
31
|
+
"web_target_invalid",
|
|
32
|
+
"The Light source URL exceeds its length limit.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return parsePublicHttpUrl(raw);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function redirectTarget(response: PublicHttpResponse, current: URL): URL {
|
|
39
|
+
const raw = response.headers.location;
|
|
40
|
+
const location = Array.isArray(raw) ? raw[0] : raw;
|
|
41
|
+
if (!location) {
|
|
42
|
+
throw new WebPluginError(
|
|
43
|
+
"web_redirect_invalid",
|
|
44
|
+
"The source redirect has no destination.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return parseLightUrl(new URL(location, current).toString());
|
|
49
|
+
} catch {
|
|
50
|
+
throw new WebPluginError(
|
|
51
|
+
"web_redirect_invalid",
|
|
52
|
+
"The source redirect has an invalid or non-public destination.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function needsOriginCooldown(status: number): boolean {
|
|
58
|
+
return status === 429 || status === 503;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cooldownDuration(
|
|
62
|
+
response: PublicHttpResponse,
|
|
63
|
+
fallback: number,
|
|
64
|
+
): number {
|
|
65
|
+
const raw = response.headers["retry-after"];
|
|
66
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
67
|
+
if (!value) return fallback;
|
|
68
|
+
const numeric = /^[0-9]+$/u.test(value)
|
|
69
|
+
? Number(value) * 1_000
|
|
70
|
+
: Date.parse(value) - Date.now();
|
|
71
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
72
|
+
return Math.max(fallback, Math.min(86_400_000, numeric));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createLightTransport(params: {
|
|
76
|
+
httpClient: PublicHttpClient;
|
|
77
|
+
config: LightConfig;
|
|
78
|
+
}): Readonly<{ createSession(abortSignal?: AbortSignal): LightCrawlSession }> {
|
|
79
|
+
const { config, httpClient } = params;
|
|
80
|
+
const origins = new Set(
|
|
81
|
+
config.sources.flatMap((source) => source.allowedOrigins),
|
|
82
|
+
);
|
|
83
|
+
const gate = createOriginGate(
|
|
84
|
+
config.maxConcurrency,
|
|
85
|
+
config.maxRequestsPerOrigin,
|
|
86
|
+
);
|
|
87
|
+
const robots = createRobotsPolicy(config, origins);
|
|
88
|
+
const cooldowns = new Map<string, number>();
|
|
89
|
+
|
|
90
|
+
const assertAdmittedOrigin = (url: URL) => {
|
|
91
|
+
if (origins.has(url.origin)) return;
|
|
92
|
+
throw new WebPluginError(
|
|
93
|
+
"web_search_source_blocked",
|
|
94
|
+
"The URL is outside the configured Light source origins.",
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const assertOriginReady = (origin: string) => {
|
|
99
|
+
const until = cooldowns.get(origin);
|
|
100
|
+
if (until === undefined) return;
|
|
101
|
+
if (until > Date.now()) {
|
|
102
|
+
throw new WebPluginError(
|
|
103
|
+
"web_search_source_blocked",
|
|
104
|
+
"The source is temporarily unavailable for Light crawling.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
cooldowns.delete(origin);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
createSession(abortSignal) {
|
|
112
|
+
const budget = createSessionBudget(config, abortSignal);
|
|
113
|
+
const fetchHop = async (url: URL): Promise<PublicHttpResponse> => {
|
|
114
|
+
budget.assertActive();
|
|
115
|
+
assertOriginReady(url.origin);
|
|
116
|
+
return await gate.run(
|
|
117
|
+
url.origin,
|
|
118
|
+
budget.hardDeadline,
|
|
119
|
+
budget.signal,
|
|
120
|
+
async () => {
|
|
121
|
+
budget.assertActive();
|
|
122
|
+
assertOriginReady(url.origin);
|
|
123
|
+
const maxBytes = budget.reserveRequest();
|
|
124
|
+
let response: PublicHttpResponse;
|
|
125
|
+
try {
|
|
126
|
+
response = await httpClient.get({
|
|
127
|
+
url: url.toString(),
|
|
128
|
+
followRedirects: false,
|
|
129
|
+
maxBytes,
|
|
130
|
+
timeoutMs: Math.min(
|
|
131
|
+
config.requestTimeoutMs,
|
|
132
|
+
Math.max(1, budget.hardDeadline - Date.now()),
|
|
133
|
+
),
|
|
134
|
+
abortSignal: budget.signal,
|
|
135
|
+
headers: {
|
|
136
|
+
Accept:
|
|
137
|
+
"text/html,application/xhtml+xml,application/rss+xml,application/atom+xml,application/xml,text/xml,text/plain",
|
|
138
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
budget.completeRequest(maxBytes, maxBytes);
|
|
143
|
+
budget.assertActive();
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
budget.completeRequest(response.bytesRead, maxBytes);
|
|
147
|
+
budget.assertActive();
|
|
148
|
+
if (needsOriginCooldown(response.status)) {
|
|
149
|
+
if (cooldowns.size >= 512)
|
|
150
|
+
cooldowns.delete(cooldowns.keys().next().value!);
|
|
151
|
+
cooldowns.set(
|
|
152
|
+
url.origin,
|
|
153
|
+
Date.now() +
|
|
154
|
+
cooldownDuration(response, config.originCooldownMs),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return response;
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const fetchRobots = async (initial: URL): Promise<PublicHttpResponse> => {
|
|
163
|
+
let current = initial;
|
|
164
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
165
|
+
const response = await fetchHop(current);
|
|
166
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
167
|
+
if (redirects >= config.maxRobotsRedirects) {
|
|
168
|
+
throw new WebPluginError(
|
|
169
|
+
"web_redirect_limit_exceeded",
|
|
170
|
+
"The robots request exceeded its redirect limit.",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
current = redirectTarget(response, current);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const policy = robots.createSession(fetchRobots, budget);
|
|
177
|
+
|
|
178
|
+
const get = async (rawUrl: string): Promise<PublicHttpResponse> => {
|
|
179
|
+
const initial = parseLightUrl(rawUrl);
|
|
180
|
+
let current = initial;
|
|
181
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
182
|
+
budget.assertActive();
|
|
183
|
+
assertAdmittedOrigin(current);
|
|
184
|
+
assertOriginReady(current.origin);
|
|
185
|
+
// Robots checks run before transport permits are held, including after redirects.
|
|
186
|
+
await policy.assertAllowed(current);
|
|
187
|
+
const response = await fetchHop(current);
|
|
188
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
...response,
|
|
191
|
+
requestedUrl: initial.toString(),
|
|
192
|
+
finalUrl: current.toString(),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (redirects >= config.maxRedirects) {
|
|
196
|
+
throw new WebPluginError(
|
|
197
|
+
"web_redirect_limit_exceeded",
|
|
198
|
+
"The Light source exceeded its redirect limit.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
current = redirectTarget(response, current);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return Object.freeze({
|
|
206
|
+
get,
|
|
207
|
+
canContinue: budget.canContinue,
|
|
208
|
+
assertActive: budget.assertActive,
|
|
209
|
+
consumeDecodedBytes: budget.consumeDecodedBytes,
|
|
210
|
+
snapshot: budget.snapshot,
|
|
211
|
+
sitemapsFor: policy.sitemapsFor,
|
|
212
|
+
dispose: budget.dispose,
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { parsePublicHttpUrl } from "../../network-policy.js";
|
|
2
|
+
import type { MarkupElement } from "./markup-tree.js";
|
|
3
|
+
|
|
4
|
+
export function boundedDiscoveryText(value: string, maxChars = 800): string {
|
|
5
|
+
return value
|
|
6
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
|
7
|
+
.replace(/\s+/gu, " ")
|
|
8
|
+
.trim()
|
|
9
|
+
.slice(0, maxChars);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveDiscoveryUrl(
|
|
13
|
+
value: string,
|
|
14
|
+
base: string,
|
|
15
|
+
): string | undefined {
|
|
16
|
+
if (!value || value.length > 4_096) return undefined;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = parsePublicHttpUrl(new URL(value.trim(), base).toString());
|
|
19
|
+
parsed.hash = "";
|
|
20
|
+
return parsed.toString();
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function elementBaseUrl(
|
|
27
|
+
element: MarkupElement,
|
|
28
|
+
documentUrl: string,
|
|
29
|
+
): string {
|
|
30
|
+
const ancestors: MarkupElement[] = [];
|
|
31
|
+
let current: MarkupElement | undefined = element;
|
|
32
|
+
while (current) {
|
|
33
|
+
ancestors.push(current);
|
|
34
|
+
current = current.parent;
|
|
35
|
+
}
|
|
36
|
+
let base = documentUrl;
|
|
37
|
+
for (const ancestor of ancestors.reverse()) {
|
|
38
|
+
const declared = ancestor.attributes["xml:base"];
|
|
39
|
+
if (declared) base = resolveDiscoveryUrl(declared, base) ?? base;
|
|
40
|
+
}
|
|
41
|
+
return base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizedSourceDate(value: string): string | undefined {
|
|
45
|
+
const bounded = value.trim().slice(0, 100);
|
|
46
|
+
if (!bounded || !/[0-9]{4}/u.test(bounded)) return undefined;
|
|
47
|
+
const timestamp = Date.parse(bounded);
|
|
48
|
+
return Number.isFinite(timestamp)
|
|
49
|
+
? new Date(timestamp).toISOString()
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function assertCandidateLimit(maxCandidates: number): void {
|
|
54
|
+
if (
|
|
55
|
+
!Number.isSafeInteger(maxCandidates) ||
|
|
56
|
+
maxCandidates < 0 ||
|
|
57
|
+
maxCandidates > 10_000
|
|
58
|
+
) {
|
|
59
|
+
throw new RangeError(
|
|
60
|
+
"maxCandidates must be an integer between 0 and 10000",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LightDocument,
|
|
3
|
+
DiscoveryCandidate,
|
|
4
|
+
} from "../documents/document-contract.js";
|
|
5
|
+
import {
|
|
6
|
+
boundedDiscoveryText,
|
|
7
|
+
elementBaseUrl,
|
|
8
|
+
normalizedSourceDate,
|
|
9
|
+
resolveDiscoveryUrl,
|
|
10
|
+
} from "./candidate-values.js";
|
|
11
|
+
import {
|
|
12
|
+
childElements,
|
|
13
|
+
descendants,
|
|
14
|
+
elementText,
|
|
15
|
+
firstChildText,
|
|
16
|
+
localName,
|
|
17
|
+
parseMarkup,
|
|
18
|
+
type MarkupElement,
|
|
19
|
+
} from "./markup-tree.js";
|
|
20
|
+
|
|
21
|
+
function feedEntryUrl(
|
|
22
|
+
entry: MarkupElement,
|
|
23
|
+
documentUrl: string,
|
|
24
|
+
): string | undefined {
|
|
25
|
+
const children = childElements(entry);
|
|
26
|
+
const link = children.find((child) => {
|
|
27
|
+
if (localName(child) !== "link") return false;
|
|
28
|
+
const relation = child.attributes.rel?.toLowerCase();
|
|
29
|
+
return !relation || relation === "alternate";
|
|
30
|
+
});
|
|
31
|
+
if (link) {
|
|
32
|
+
const value = link.attributes.href ?? elementText(link);
|
|
33
|
+
const resolved = resolveDiscoveryUrl(
|
|
34
|
+
value,
|
|
35
|
+
elementBaseUrl(link, documentUrl),
|
|
36
|
+
);
|
|
37
|
+
if (resolved) return resolved;
|
|
38
|
+
}
|
|
39
|
+
const guid = children.find((child) => localName(child) === "guid");
|
|
40
|
+
if (!guid || guid.attributes.isPermaLink?.toLowerCase() === "false")
|
|
41
|
+
return undefined;
|
|
42
|
+
return resolveDiscoveryUrl(
|
|
43
|
+
elementText(guid),
|
|
44
|
+
elementBaseUrl(guid, documentUrl),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readableFeedExcerpt(entry: MarkupElement): string {
|
|
49
|
+
const raw = firstChildText(entry, [
|
|
50
|
+
"description",
|
|
51
|
+
"summary",
|
|
52
|
+
"content",
|
|
53
|
+
"encoded",
|
|
54
|
+
]).slice(0, 8_000);
|
|
55
|
+
if (!raw.includes("<")) return boundedDiscoveryText(raw);
|
|
56
|
+
return boundedDiscoveryText(elementText(parseMarkup(raw, false)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function feedCandidate(
|
|
60
|
+
entry: MarkupElement,
|
|
61
|
+
documentUrl: string,
|
|
62
|
+
): DiscoveryCandidate | undefined {
|
|
63
|
+
const url = feedEntryUrl(entry, documentUrl);
|
|
64
|
+
if (!url) return undefined;
|
|
65
|
+
const publishedAt = normalizedSourceDate(
|
|
66
|
+
firstChildText(entry, ["published", "pubdate", "date"]),
|
|
67
|
+
);
|
|
68
|
+
const updatedAt = normalizedSourceDate(
|
|
69
|
+
firstChildText(entry, ["updated", "modified"]),
|
|
70
|
+
);
|
|
71
|
+
const text = readableFeedExcerpt(entry);
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
url,
|
|
74
|
+
title: boundedDiscoveryText(firstChildText(entry, ["title"]), 180),
|
|
75
|
+
kind: "page",
|
|
76
|
+
...(text ? { text } : {}),
|
|
77
|
+
...(publishedAt ? { publishedAt } : {}),
|
|
78
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseFeedDocument(
|
|
83
|
+
root: MarkupElement,
|
|
84
|
+
documentUrl: string,
|
|
85
|
+
maxCandidates: number,
|
|
86
|
+
): LightDocument {
|
|
87
|
+
const elements = descendants(root);
|
|
88
|
+
const container =
|
|
89
|
+
elements.find((element) => localName(element) === "channel") ??
|
|
90
|
+
childElements(root)[0] ??
|
|
91
|
+
root;
|
|
92
|
+
const entries = elements.filter((element) =>
|
|
93
|
+
["item", "entry"].includes(localName(element)),
|
|
94
|
+
);
|
|
95
|
+
const links: DiscoveryCandidate[] = [];
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
let partial = false;
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const candidate = feedCandidate(entry, documentUrl);
|
|
100
|
+
if (!candidate || seen.has(candidate.url)) continue;
|
|
101
|
+
if (links.length >= maxCandidates) {
|
|
102
|
+
partial = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
seen.add(candidate.url);
|
|
106
|
+
links.push(candidate);
|
|
107
|
+
}
|
|
108
|
+
const publishedAt = normalizedSourceDate(
|
|
109
|
+
firstChildText(container, ["published", "pubdate", "date"]),
|
|
110
|
+
);
|
|
111
|
+
const updatedAt = normalizedSourceDate(
|
|
112
|
+
firstChildText(container, ["updated", "lastbuilddate"]),
|
|
113
|
+
);
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
canonicalUrl: documentUrl,
|
|
116
|
+
title: boundedDiscoveryText(firstChildText(container, ["title"]), 180),
|
|
117
|
+
text: boundedDiscoveryText(
|
|
118
|
+
firstChildText(container, ["description", "subtitle"]),
|
|
119
|
+
1_200,
|
|
120
|
+
),
|
|
121
|
+
kind: "feed",
|
|
122
|
+
links: Object.freeze(links),
|
|
123
|
+
partial,
|
|
124
|
+
...(publishedAt ? { publishedAt } : {}),
|
|
125
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { DiscoveryCandidate } from "../documents/document-contract.js";
|
|
2
|
+
import {
|
|
3
|
+
boundedDiscoveryText,
|
|
4
|
+
resolveDiscoveryUrl,
|
|
5
|
+
} from "./candidate-values.js";
|
|
6
|
+
import {
|
|
7
|
+
descendants,
|
|
8
|
+
elementText,
|
|
9
|
+
localName,
|
|
10
|
+
type MarkupElement,
|
|
11
|
+
} from "./markup-tree.js";
|
|
12
|
+
import { readOriginDates } from "./origin-dates.js";
|
|
13
|
+
import { readHtmlRobotsDirectives } from "./html-robots-directives.js";
|
|
14
|
+
|
|
15
|
+
export type HtmlDiscovery = Readonly<{
|
|
16
|
+
links: readonly DiscoveryCandidate[];
|
|
17
|
+
partial: boolean;
|
|
18
|
+
noIndex: boolean;
|
|
19
|
+
publishedAt?: string;
|
|
20
|
+
updatedAt?: string;
|
|
21
|
+
}>;
|
|
22
|
+
|
|
23
|
+
function relationTokens(element: MarkupElement): readonly string[] {
|
|
24
|
+
return (element.attributes.rel ?? "").toLowerCase().split(/\s+/u);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isFeedAdvertisement(element: MarkupElement): boolean {
|
|
28
|
+
if (localName(element) !== "link") return false;
|
|
29
|
+
if (!relationTokens(element).includes("alternate")) return false;
|
|
30
|
+
return ["application/rss+xml", "application/atom+xml"].includes(
|
|
31
|
+
(element.attributes.type ?? "").toLowerCase().split(";", 1)[0]!.trim(),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function discoverHtmlLinks(
|
|
36
|
+
root: MarkupElement,
|
|
37
|
+
documentUrl: string,
|
|
38
|
+
maxCandidates: number,
|
|
39
|
+
): HtmlDiscovery {
|
|
40
|
+
const elements = descendants(root);
|
|
41
|
+
const baseElement = elements.find(
|
|
42
|
+
(element) => localName(element) === "base" && !!element.attributes.href,
|
|
43
|
+
);
|
|
44
|
+
const base =
|
|
45
|
+
resolveDiscoveryUrl(baseElement?.attributes.href ?? "", documentUrl) ??
|
|
46
|
+
documentUrl;
|
|
47
|
+
const links: DiscoveryCandidate[] = [];
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
let partial = false;
|
|
50
|
+
const robots = readHtmlRobotsDirectives(elements);
|
|
51
|
+
for (const element of elements) {
|
|
52
|
+
if (robots.noFollow) break;
|
|
53
|
+
const isFeed = isFeedAdvertisement(element);
|
|
54
|
+
if (!isFeed && localName(element) !== "a") continue;
|
|
55
|
+
if (relationTokens(element).includes("nofollow")) continue;
|
|
56
|
+
const url = resolveDiscoveryUrl(element.attributes.href ?? "", base);
|
|
57
|
+
if (!url || seen.has(url)) continue;
|
|
58
|
+
if (links.length >= maxCandidates) {
|
|
59
|
+
partial = true;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
seen.add(url);
|
|
63
|
+
links.push(
|
|
64
|
+
Object.freeze({
|
|
65
|
+
url,
|
|
66
|
+
title: boundedDiscoveryText(
|
|
67
|
+
element.attributes.title ?? elementText(element),
|
|
68
|
+
180,
|
|
69
|
+
),
|
|
70
|
+
kind: isFeed ? "feed" : "page",
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
links: Object.freeze(links),
|
|
76
|
+
partial,
|
|
77
|
+
noIndex: robots.noIndex,
|
|
78
|
+
...readOriginDates(elements),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { localName, type MarkupElement } from "./markup-tree.js";
|
|
2
|
+
|
|
3
|
+
export type HtmlRobotsDirectives = Readonly<{
|
|
4
|
+
noIndex: boolean;
|
|
5
|
+
noFollow: boolean;
|
|
6
|
+
}>;
|
|
7
|
+
|
|
8
|
+
function isRobotsMeta(element: MarkupElement): boolean {
|
|
9
|
+
if (localName(element) !== "meta") return false;
|
|
10
|
+
return (element.attributes.name ?? "").trim().toLowerCase() === "robots";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readHtmlRobotsDirectives(
|
|
14
|
+
elements: readonly MarkupElement[],
|
|
15
|
+
): HtmlRobotsDirectives {
|
|
16
|
+
const directives = new Set(
|
|
17
|
+
elements
|
|
18
|
+
.filter(isRobotsMeta)
|
|
19
|
+
.flatMap((element) =>
|
|
20
|
+
(element.attributes.content ?? "").toLowerCase().split(/[\s,]+/u),
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
if (directives.has("none"))
|
|
24
|
+
return Object.freeze({ noIndex: true, noFollow: true });
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
noIndex: directives.has("noindex"),
|
|
27
|
+
noFollow: directives.has("nofollow"),
|
|
28
|
+
});
|
|
29
|
+
}
|