agentlinkops 0.6.11 → 0.6.16
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/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.mcp.json +0 -0
- package/CHANGELOG.md +35 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/PACK-ARTIFACT.json +16 -0
- package/PACKS.json +591 -0
- package/README.md +34 -48
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +1 -1
- package/cli/admission.js +0 -0
- package/cli/agent-setup.js +101 -17
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/campaign-bridge.js +325 -0
- package/cli/campaign-outcomes.js +107 -0
- package/cli/catalog.js +47686 -16398
- package/cli/check.js +40 -10
- package/cli/chronology.js +39 -0
- package/cli/citation.js +0 -0
- package/cli/client.js +2 -1
- package/cli/cloud-error.js +0 -0
- package/cli/commands.js +17 -8
- package/cli/config.js +0 -0
- package/cli/connection.js +92 -18
- package/cli/contact-adoption.js +91 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +13 -2
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +0 -0
- package/cli/doctor.js +15 -4
- package/cli/env.js +0 -0
- package/cli/fleet.js +30 -19
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +7 -1
- package/cli/index-observations.js +0 -0
- package/cli/index-submissions.js +0 -0
- package/cli/key-store.js +115 -0
- package/cli/ledger.js +1 -0
- package/cli/lifecycle.js +0 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +41 -6
- package/cli/main.js +85 -20
- package/cli/mcp-client.js +2 -2
- package/cli/migration.js +308 -0
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/native-lock.js +0 -0
- package/cli/oauth-credentials.js +0 -0
- package/cli/oauth-flow.js +0 -0
- package/cli/placement-projection.js +106 -0
- package/cli/platforms.js +0 -0
- package/cli/prompt.js +25 -0
- package/cli/public-http.js +168 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +51 -19
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +0 -0
- package/cli/state.js +76 -29
- package/cli/status.js +14 -18
- package/cli/sync-lock.js +0 -0
- package/cli/sync.js +23 -4
- package/package.json +7 -8
- package/references/citation-panels.md +2 -0
- package/references/cli.md +2 -2
- package/references/disavow.md +1 -1
- package/references/discovery-handoff.md +1 -1
- package/references/local-crm-draft-handoff.md +1 -1
- package/references/portable-ledger-handoff.md +11 -10
- package/references/recipes/catalog.json +263 -7
- package/references/recipes/command-notes.md +5 -3
- package/references/recipes/internal-link-improvement.md +25 -0
- package/references/recipes/placement-reconciliation.md +53 -1
- package/references/recipes/qualified-campaign-handoff.md +0 -0
- package/references/recipes/site-context-brief.md +0 -0
- package/references/recipes/sourced-linkable-asset.md +0 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/outreach-crm-draft.py +0 -0
- package/scripts/portable-handoff.py +17 -4
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +3 -2
- package/shared/admission-report-contract.js +3 -2
- package/shared/agent-clients.js +0 -0
- package/shared/analytics-history-contract.js +15 -0
- package/shared/campaign-outcome-contract.js +12 -0
- package/shared/campaign-prospect-contract.js +23 -0
- package/shared/citation-evidence-contract.js +0 -0
- package/shared/classification-contract.js +54 -0
- package/shared/contact-record-contract.js +27 -0
- package/shared/disavow-contract.js +37 -9
- package/shared/exec-grammar.js +0 -0
- package/shared/index-observation-contract.js +5 -4
- package/shared/internal-links-contract.js +143 -0
- package/shared/lifecycle-contract.js +0 -0
- package/shared/lifecycle-money.js +0 -0
- package/shared/link-category-contract.js +35 -0
- package/shared/link-category.js +138 -0
- package/shared/link-labels.js +26 -0
- package/shared/link-summary-contract.js +17 -0
- package/shared/monitoring-chronology-export.js +57 -0
- package/shared/monitoring-read-contract.js +41 -0
- package/shared/observation-event.js +1 -1
- package/shared/onboarding.js +20 -0
- package/shared/opportunity-record.js +0 -0
- package/shared/response-contract.js +41 -5
- package/shared/search-connection-contract.js +2 -1
- package/shared/table-page-contract.js +13 -0
- package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
- package/skills/agentlinkops-assets/SKILL.md +3 -1
- package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
- package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
- package/skills/agentlinkops-campaigns/SKILL.md +7 -5
- package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
- package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
- package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
- package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
- package/skills/agentlinkops-connect/SKILL.md +2 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
- package/skills/agentlinkops-connect/references/agentlinkops.md +164 -61
- package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
- package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
- package/skills/agentlinkops-crm/SKILL.md +26 -9
- package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
- package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
- package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
- package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
- package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
- package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
- package/skills/agentlinkops-discovery/SKILL.md +4 -2
- package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
- package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
- package/src/citations/adapter.js +0 -0
- package/src/citations/alert.js +0 -0
- package/src/citations/browser/chatgpt-citations.js +0 -0
- package/src/citations/browser/engine.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +0 -0
- package/src/citations/browser/proxy-admission.js +0 -0
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +0 -0
- package/src/citations/contract.js +0 -0
- package/src/citations/egress-receipts.js +0 -0
- package/src/citations/evidence-integrity.js +0 -0
- package/src/citations/inventory.js +0 -0
- package/src/citations/login.js +0 -0
- package/src/citations/match.js +0 -0
- package/src/citations/panel-builder.js +0 -0
- package/src/citations/report.js +0 -0
- package/src/citations/runner.js +0 -0
- package/src/citations/stats.js +0 -0
- package/src/citations/sync.js +0 -0
- package/src/citations/watch.js +0 -0
- package/src/competitors/contract.js +17 -1
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +34 -121
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +68 -32
- package/src/corpus/extract.js +147 -0
- package/src/corpus/frontier.js +151 -0
- package/src/corpus/seeds.js +0 -0
- package/src/discovery/contract.js +8 -0
- package/src/discovery/import.js +13 -1
- package/src/evidence-reference.js +0 -0
- package/src/index-observation.js +0 -0
- package/src/locator.js +0 -0
- package/src/mentions.js +193 -0
- package/src/page-acquisition/acquire.js +50 -0
- package/src/page-acquisition/bytes.js +138 -0
- package/src/page-acquisition/capture.js +11 -0
- package/src/page-acquisition/contracts.js +67 -0
- package/src/page-acquisition/documents.js +166 -0
- package/src/page-acquisition/http.js +307 -0
- package/src/page-acquisition/policy.js +20 -0
- package/src/page-acquisition/recovery.js +164 -0
- package/src/page-acquisition/render-failure.js +82 -0
- package/src/page-extraction/html.js +359 -0
- package/src/page-extraction/index.js +30 -0
- package/src/page-extraction/metadata.js +16 -0
- package/src/response-shaping.js +4 -3
- package/src/tool-views.js +23 -12
- package/src/verifier/fetch.js +4 -339
- package/src/verifier/index.js +81 -23
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/page-quality.js +14 -0
- package/src/verifier/parser.js +2 -241
- package/src/verifier/readiness.js +64 -13
- package/src/verifier/render-evidence.js +25 -2
- package/src/verifier/robots.js +0 -0
- package/src/verifier/target.js +201 -0
- package/src/verifier/transition.js +18 -3
- package/src/verifier/url.js +31 -0
- package/src/watch-crawlability.js +144 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/skills/content-defingerprinting/SKILL.md +0 -29
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
- package/skills/internal-linking-optimizer/SKILL.md +0 -56
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
- package/skills/pre-publish-review/SKILL.md +0 -179
- package/skills/source-cited-content-builder/SKILL.md +0 -56
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
- package/skills/source-cited-humanizer/SKILL.md +0 -20
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/citations/login.js
CHANGED
|
File without changes
|
package/src/citations/match.js
CHANGED
|
File without changes
|
|
File without changes
|
package/src/citations/report.js
CHANGED
|
File without changes
|
package/src/citations/runner.js
CHANGED
|
File without changes
|
package/src/citations/stats.js
CHANGED
|
File without changes
|
package/src/citations/sync.js
CHANGED
|
File without changes
|
package/src/citations/watch.js
CHANGED
|
File without changes
|
|
@@ -22,7 +22,23 @@ export const TargetScope = z.strictObject({ target_kind: z.enum(['domain', 'exac
|
|
|
22
22
|
if (!DiscoveryQuery.safeParse({ ...scope, ...policy }).success)
|
|
23
23
|
ctx.addIssue({ code: 'custom', message: 'Expected an explicit canonical discovery target scope.' });
|
|
24
24
|
});
|
|
25
|
-
|
|
25
|
+
// Customer-authored selection context, not a provider verdict or evidence of a link.
|
|
26
|
+
// Keep this optional with no defaults: parsing legacy revisions must not add bytes
|
|
27
|
+
// to the set hash embedded in their immutable inventory snapshots.
|
|
28
|
+
export const CompetitorSelectionRationale = z.strictObject({
|
|
29
|
+
relationship: z.enum(['business', 'search', 'shared_backlink_neighbor', 'unknown']),
|
|
30
|
+
audience: z.string().trim().min(1).max(500).nullable(),
|
|
31
|
+
topic: z.string().trim().min(1).max(500).nullable(),
|
|
32
|
+
evidence: z.array(z.strictObject({
|
|
33
|
+
source_url: z.string().min(1).max(2048).refine(value => {
|
|
34
|
+
const checked = validatePublicUrl(value);
|
|
35
|
+
return checked.valid && checked.url === value;
|
|
36
|
+
}, 'Expected a canonical public HTTP(S) source URL.'),
|
|
37
|
+
note: z.string().trim().min(1).max(1000),
|
|
38
|
+
})).max(5),
|
|
39
|
+
});
|
|
40
|
+
const memberSchema = z.strictObject({ id: identifier, role: z.enum(['customer', 'competitor']), scope: TargetScope,
|
|
41
|
+
selection_rationale: CompetitorSelectionRationale.optional() });
|
|
26
42
|
export const ApprovedCompetitorSet = z.strictObject({
|
|
27
43
|
v: z.literal(COMPETITOR_VERSION), id: identifier, revision: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
|
|
28
44
|
workspace_id: identifier, project_id: identifier, approved_by: identifier, approved_at: timestamp,
|
|
File without changes
|
package/src/context/ga4.js
CHANGED
|
File without changes
|
package/src/context/gsc.js
CHANGED
|
File without changes
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
import { SEED_LIMITS, parseSitemap, sitemapDirectives, withinSitemapScope } from '../corpus/seeds.js';
|
|
30
30
|
import { createAcquisition } from '../corpus/acquire.js';
|
|
31
31
|
import { validatePublicUrl } from '../verifier/url.js';
|
|
32
|
-
import {
|
|
32
|
+
import { createDocumentTransport } from '../page-acquisition/documents.js';
|
|
33
|
+
import { extractPageMetadata } from '../page-extraction/metadata.js';
|
|
33
34
|
import { readJsonl, appendJsonl, sha256Hex } from './util.js';
|
|
34
35
|
import { readManual } from './gsc.js';
|
|
35
36
|
|
|
@@ -52,93 +53,10 @@ export class ProfileError extends Error {
|
|
|
52
53
|
// decompression (the acquisition layer's job per seeds.js), and per-host courtesy pacing.
|
|
53
54
|
// ---------------------------------------------------------------------------
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*/
|
|
60
|
-
async function readBoundedBytes(response, maxBytes, signal) {
|
|
61
|
-
const length = response.headers.get('content-length');
|
|
62
|
-
const encoded = response.headers.get('content-encoding');
|
|
63
|
-
if (!encoded && length && Number(length) > maxBytes) {
|
|
64
|
-
response.body?.cancel().catch(() => {});
|
|
65
|
-
throw new ProfileError('body_too_large');
|
|
66
|
-
}
|
|
67
|
-
const reader = response.body?.getReader();
|
|
68
|
-
if (!reader) return { data: new Uint8Array(0), bytes: 0 };
|
|
69
|
-
const chunks = [];
|
|
70
|
-
let bytes = 0;
|
|
71
|
-
try {
|
|
72
|
-
for (;;) {
|
|
73
|
-
const { done, value } = await reader.read();
|
|
74
|
-
if (done) break;
|
|
75
|
-
bytes += value.byteLength;
|
|
76
|
-
if (bytes > maxBytes) throw new ProfileError('body_too_large');
|
|
77
|
-
chunks.push(value);
|
|
78
|
-
}
|
|
79
|
-
const combined = new Uint8Array(bytes);
|
|
80
|
-
let offset = 0;
|
|
81
|
-
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
|
82
|
-
return { data: combined, bytes };
|
|
83
|
-
} finally {
|
|
84
|
-
reader.cancel().catch(() => {});
|
|
85
|
-
reader.releaseLock();
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function createSiteTransport({ fetchImpl = globalThis.fetch, timeoutMs = 20_000, maxBytes = SEED_LIMITS.documentBytes, maxRedirects = 5, hostDelayMs = PROFILE_LIMITS.hostDelayMs, now = () => Date.now(), wait = ms => new Promise(r => setTimeout(r, ms)) } = {}) {
|
|
90
|
-
const nextAllowed = new Map();
|
|
91
|
-
async function pace(host) {
|
|
92
|
-
const allowed = nextAllowed.get(host) ?? 0;
|
|
93
|
-
const delay = allowed - now();
|
|
94
|
-
if (delay > 0) await wait(delay);
|
|
95
|
-
nextAllowed.set(host, now() + hostDelayMs);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function fetchText(rawUrl, { accept = 'text/plain,application/xml,text/xml' } = {}) {
|
|
99
|
-
const checked = validatePublicUrl(rawUrl);
|
|
100
|
-
if (!checked.valid) return { ok: false, url: rawUrl, reason: `unsafe_url:${checked.reason}` };
|
|
101
|
-
let url = checked.url;
|
|
102
|
-
const seen = new Set();
|
|
103
|
-
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
104
|
-
if (seen.has(url)) return { ok: false, url, reason: 'redirect_loop' };
|
|
105
|
-
seen.add(url);
|
|
106
|
-
await pace(new URL(url).hostname);
|
|
107
|
-
const controller = new AbortController();
|
|
108
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
109
|
-
let response;
|
|
110
|
-
try {
|
|
111
|
-
response = await fetchImpl(url, { method: 'GET', redirect: 'manual', credentials: 'omit', signal: controller.signal, headers: { 'User-Agent': 'LinktrailBot/0.1', Accept: accept } });
|
|
112
|
-
} catch { return { ok: false, url, reason: 'timeout_or_network_error' }; }
|
|
113
|
-
finally { clearTimeout(timer); }
|
|
114
|
-
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
115
|
-
response.body?.cancel().catch(() => {});
|
|
116
|
-
const location = response.headers.get('location');
|
|
117
|
-
const next = location ? validatePublicUrl(new URL(location, url).href) : null;
|
|
118
|
-
if (!next?.valid) return { ok: false, url, reason: 'unsafe_or_missing_redirect' };
|
|
119
|
-
url = next.url;
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (response.status === 404 || response.status === 410) { response.body?.cancel().catch(() => {}); return { ok: false, url, status: response.status, reason: 'not_found' }; }
|
|
123
|
-
if (!response.ok) { response.body?.cancel().catch(() => {}); return { ok: false, url, status: response.status, reason: `http_${response.status}` }; }
|
|
124
|
-
let body;
|
|
125
|
-
try { body = await readBoundedBytes(response, maxBytes, controller.signal); }
|
|
126
|
-
catch (error) { return { ok: false, url, status: response.status, reason: error?.reason ?? 'read_failed' }; }
|
|
127
|
-
let data = body.data, gzipped = false;
|
|
128
|
-
if (data?.length >= 2 && data[0] === 0x1f && data[1] === 0x8b) {
|
|
129
|
-
gzipped = true;
|
|
130
|
-
try { data = gunzipSync(data); } catch { return { ok: false, url, status: response.status, reason: 'sitemap_gunzip_failed' }; }
|
|
131
|
-
if (data.byteLength > maxBytes) return { ok: false, url, status: response.status, reason: 'sitemap_too_large' };
|
|
132
|
-
}
|
|
133
|
-
let text;
|
|
134
|
-
try { text = new TextDecoder('utf-8', { fatal: true }).decode(data); }
|
|
135
|
-
catch { return { ok: false, url, status: response.status, reason: 'unsupported_or_invalid_encoding' }; }
|
|
136
|
-
return { ok: true, url, final_url: url, status: response.status, text, bytes: body.bytes, gzipped };
|
|
137
|
-
}
|
|
138
|
-
return { ok: false, url, reason: 'redirect_limit' };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return { fetchText };
|
|
56
|
+
/** Compatibility entrypoint; byte limits and abortable document reads live in one core. */
|
|
57
|
+
export function createSiteTransport(options = {}) {
|
|
58
|
+
return createDocumentTransport({ maxBytes: SEED_LIMITS.documentBytes,
|
|
59
|
+
hostDelayMs: PROFILE_LIMITS.hostDelayMs, ...options });
|
|
142
60
|
}
|
|
143
61
|
|
|
144
62
|
// ---------------------------------------------------------------------------
|
|
@@ -146,17 +64,6 @@ export function createSiteTransport({ fetchImpl = globalThis.fetch, timeoutMs =
|
|
|
146
64
|
// fetch date, matching the verifier's rule that evidence HTML never lives in a payload.
|
|
147
65
|
// ---------------------------------------------------------------------------
|
|
148
66
|
|
|
149
|
-
const firstMatch = (pattern, html, group = 1) => pattern.exec(html)?.[group]?.replace(/\s+/gu, ' ').trim() ?? null;
|
|
150
|
-
|
|
151
|
-
function boundedMatches(pattern, html, limit) {
|
|
152
|
-
const found = [];
|
|
153
|
-
for (const match of html.matchAll(pattern)) {
|
|
154
|
-
found.push(match[1].replace(/\s+/gu, ' ').trim());
|
|
155
|
-
if (found.length >= limit) break;
|
|
156
|
-
}
|
|
157
|
-
return found;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
67
|
/** Asset candidates are NOMINATIONS for judgment, never findings: path conventions the publisher chose plus names the manual declared. */
|
|
161
68
|
const ASSET_PATH_PATTERNS = Object.freeze([
|
|
162
69
|
[/\/(tools?|calculators?|templates?|checklists?|guides?)\//iu, 'asset_path_convention'],
|
|
@@ -164,19 +71,8 @@ const ASSET_PATH_PATTERNS = Object.freeze([
|
|
|
164
71
|
[/\.(pdf|xlsx?|csv)$/iu, 'downloadable_file'],
|
|
165
72
|
]);
|
|
166
73
|
|
|
167
|
-
export function derivePageFields(html) {
|
|
168
|
-
|
|
169
|
-
const links = [...html.matchAll(/<a\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/giu)];
|
|
170
|
-
return {
|
|
171
|
-
title: firstMatch(/<title[^>]*>([\s\S]*?)<\/title>/iu, html),
|
|
172
|
-
h1s: boundedMatches(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu, html, 5),
|
|
173
|
-
meta_description: (firstMatch(/<meta\b[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']*)["']/iu, html)
|
|
174
|
-
?? firstMatch(/<meta\b[^>]*content\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']description["']/iu, html))?.slice(0, 500) ?? null,
|
|
175
|
-
canonical: firstMatch(/<link\b[^>]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']+)["']/iu, html)
|
|
176
|
-
?? firstMatch(/<link\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']canonical["']/iu, html),
|
|
177
|
-
link_counts: { total: links.length },
|
|
178
|
-
asset_candidate_reasons: [], // filled by the caller, who knows the URL and the manual assets
|
|
179
|
-
};
|
|
74
|
+
export function derivePageFields(html, { baseUrl } = {}) {
|
|
75
|
+
return extractPageMetadata(html, { baseUrl });
|
|
180
76
|
}
|
|
181
77
|
|
|
182
78
|
function assetCandidateReasons(url, derived, manualAssets) {
|
|
@@ -290,6 +186,7 @@ function classifySelection(url, manualTargets, homepage, sitemapUrls) {
|
|
|
290
186
|
*/
|
|
291
187
|
export async function buildSiteProfile({
|
|
292
188
|
site = null, paths, manual = null, transport = null, acquisition = null,
|
|
189
|
+
recovery = null,
|
|
293
190
|
extraPages = [], now = () => new Date().toISOString(), limits = {},
|
|
294
191
|
}) {
|
|
295
192
|
const cap = { ...PROFILE_LIMITS, ...limits };
|
|
@@ -312,7 +209,7 @@ export async function buildSiteProfile({
|
|
|
312
209
|
// refused rather than guessing at /sitemap.xml, and the page lane proceeds under the
|
|
313
210
|
// verifier's own current robots posture.
|
|
314
211
|
const robotsUrl = `${origin}/robots.txt`;
|
|
315
|
-
const robotsFetch = await docFetch.fetchText(robotsUrl);
|
|
212
|
+
const robotsFetch = await docFetch.fetchText(robotsUrl, { purpose: 'robots' });
|
|
316
213
|
const robotsText = robotsFetch.ok ? robotsFetch.text : '';
|
|
317
214
|
const directives = robotsFetch.ok ? sitemapDirectives(robotsText, { baseUrl: robotsUrl }) : [];
|
|
318
215
|
const robotsFact = await siteFactRow('site.robots', robotsUrl, {
|
|
@@ -383,24 +280,39 @@ export async function buildSiteProfile({
|
|
|
383
280
|
const selectedFinal = selected.slice(0, cap.selectedPages);
|
|
384
281
|
|
|
385
282
|
// 4. Page fetches on the verifier's public fetch boundary, with the CLI's courtesy pacing.
|
|
386
|
-
|
|
283
|
+
// This local producer owns no Worker job or native reservation. Explicit caller
|
|
284
|
+
// adapters may recover a page; omitted privileged routes remain unsupported.
|
|
285
|
+
const pageAcquisition = acquisition ?? createAcquisition({ publicFetchSafe: true,purpose:'research',...(recovery?{recovery}:{}) });
|
|
387
286
|
const pacer = createPacer({ hostDelayMs: cap.hostDelayMs });
|
|
388
287
|
const pageRows = [];
|
|
389
288
|
for (const url of selectedFinal) {
|
|
390
289
|
const fetchAt = now();
|
|
391
|
-
const result = await pageAcquisition.fetchPage(url, { kind: 'direct', beforeFetch: pacer.beforeFetch });
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
derived
|
|
290
|
+
const result = await pageAcquisition.fetchPage(url, { kind: 'direct',purpose:'research', beforeFetch: pacer.beforeFetch,...(recovery?{recovery}:{}) });
|
|
291
|
+
const acquired = result.outcome === 'fetched';
|
|
292
|
+
let outcome = result.outcome, reason = result.reason ?? null;
|
|
293
|
+
let derived = { title: null, h1s: [], meta_description: null, canonical: null, link_counts: null };
|
|
294
|
+
if (acquired) {
|
|
295
|
+
try {
|
|
296
|
+
if (result.body_truncated === true) throw new ProfileError('body_truncated');
|
|
297
|
+
derived = derivePageFields(result.html, { baseUrl: result.final_url ?? url });
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
// A refused/incomplete parse is an explicit unknown, never an empty page or "no assets".
|
|
301
|
+
outcome = 'unavailable'; reason = error?.reason ?? 'unreadable_document';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const fetched = outcome === 'fetched';
|
|
305
|
+
derived.asset_candidate_reasons = fetched ? assetCandidateReasons(url, derived, manualInputs.assets) : null;
|
|
395
306
|
const fact = await siteFactRow('site.page', url, {
|
|
396
|
-
outcome
|
|
307
|
+
outcome, reason, http_status: result.http_status ?? null,
|
|
397
308
|
final_url: result.final_url ?? url, robots: result.robots?.reason ?? null,
|
|
398
309
|
title: derived.title, h1s: derived.h1s, meta_description: derived.meta_description,
|
|
399
310
|
canonical: derived.canonical, link_counts: derived.link_counts,
|
|
400
311
|
asset_candidate_reasons: derived.asset_candidate_reasons,
|
|
401
312
|
in_sitemap: sitemapUrls.includes(url),
|
|
402
313
|
selection_reasons: classifySelection(url, manualInputs.target_pages ?? [], homepage, sitemapUrls),
|
|
403
|
-
bytes: result.bytes ?? null, content_sha256:
|
|
314
|
+
bytes: result.bytes ?? null, content_sha256: acquired ? await sha256Hex(result.html ?? '') : null,
|
|
315
|
+
...(result.acquisition?{acquisition:result.acquisition}:{}),
|
|
404
316
|
fetched_at: fetchAt,
|
|
405
317
|
});
|
|
406
318
|
facts.push(fact); pageRows.push(fact);
|
|
@@ -417,7 +329,8 @@ export async function buildSiteProfile({
|
|
|
417
329
|
capped: sitemapCapped, directives_seen: directives.length,
|
|
418
330
|
failures: sitemapFailures, skipped: sitemapSkipped,
|
|
419
331
|
},
|
|
420
|
-
pages: pageRows.map(row => ({ url: row.url, outcome: row.outcome, reason: row.reason, title: row.title, in_sitemap: row.in_sitemap, selection_reasons: row.selection_reasons, asset_candidate_reasons: row.asset_candidate_reasons
|
|
332
|
+
pages: pageRows.map(row => ({ url: row.url, outcome: row.outcome, reason: row.reason, title: row.title, in_sitemap: row.in_sitemap, selection_reasons: row.selection_reasons, asset_candidate_reasons: row.asset_candidate_reasons,
|
|
333
|
+
...(row.acquisition?{acquisition:row.acquisition}:{}) })),
|
|
421
334
|
limits: { sitemap_documents: cap.sitemapDocuments, selected_pages: cap.selectedPages, pages_capped: pagesCapped, pages_selected: pageRows.length },
|
|
422
335
|
rows_written: facts.length,
|
|
423
336
|
notes: [
|
package/src/context/util.js
CHANGED
|
File without changes
|
|
File without changes
|
package/src/corpus/acquire.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPageAcquisition } from '../page-acquisition/acquire.js';
|
|
2
2
|
import { validatePublicUrl } from '../verifier/url.js';
|
|
3
|
+
import {runRecovery,recoverySummary} from '../page-acquisition/recovery.js';
|
|
4
|
+
import {extractEdges,ExtractionError} from './extract.js';
|
|
3
5
|
|
|
4
6
|
// One interface over every way we acquire a page, so a runtime can be added without
|
|
5
7
|
// rebuilding the corpus. Direct fetching is implemented here on the verifier's public
|
|
@@ -51,24 +53,22 @@ export function createAcquisition(options = {}) {
|
|
|
51
53
|
const runtimes = options.runtimes ?? {};
|
|
52
54
|
const clock = options.now ?? (() => new Date().toISOString());
|
|
53
55
|
|
|
54
|
-
async function direct(url, beforeFetch, { validators = null, document = false
|
|
55
|
-
|
|
56
|
-
//
|
|
56
|
+
async function direct(url, beforeFetch, { validators = null, document = false, alternate = null, method = 'workers_http',
|
|
57
|
+
signal = options.signal, deadlineAt = options.deadlineAt } = {}) {
|
|
58
|
+
// Preserve the established compatibility refusal. New shared capture metadata does
|
|
59
|
+
// not establish public egress qualification or activate the stopped corpus machinery.
|
|
57
60
|
if (options.publicFetchSafe !== true) throw new AcquisitionError('public_fetch_boundary_not_asserted');
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} finally {
|
|
70
|
-
clearTimeout(timer);
|
|
71
|
-
}
|
|
61
|
+
const acquisition = createPageAcquisition({ ...options, beforeFetch: beforeFetch ?? options.beforeFetch,
|
|
62
|
+
...(alternate?{fetchImpl:(target,settings)=>alternate.fetch(target,{...settings,deadlineAt})}:{}),
|
|
63
|
+
transportId: alternate?method:options.captureTransport?.id ?? 'public_http',
|
|
64
|
+
transportVersion: alternate?'alo-public-node-v1':options.captureTransport?.version ?? '1' });
|
|
65
|
+
const { response, body, notModified, validators: earned, trace, capture } = await acquisition.acquire(url,
|
|
66
|
+
{ maxBytes, timeoutMs, validators, document, signal, deadlineAt });
|
|
67
|
+
if (notModified) return { outcome: 'unchanged', trace, html: null, validators: earned, capture: null };
|
|
68
|
+
if (NOT_FOUND.has(response.status)) return { outcome: 'not_found', trace, html: null, capture: null };
|
|
69
|
+
return { outcome: 'fetched', trace, html: body?.text ?? '', bytes: body?.bytes ?? 0, capture,
|
|
70
|
+
linkHeader: response.headers.get('link'),
|
|
71
|
+
validators: { etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') } };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
@@ -85,6 +85,8 @@ export function createAcquisition(options = {}) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
|
+
recoveryManaged:true,
|
|
89
|
+
maxFetches:1+(options.recovery?.alternate?1:0)+(runtimes.rendered?.publicFetchSafe===true?1:0)+(runtimes.proxied?.publicFetchSafe===true?1:0),
|
|
88
90
|
/**
|
|
89
91
|
* Whether a fetch kind can actually be served. The crawl loop asks before escalating a
|
|
90
92
|
* page to a browser, because queueing work no runtime can serve would leave the frontier
|
|
@@ -104,7 +106,7 @@ export function createAcquisition(options = {}) {
|
|
|
104
106
|
* previous fetch of this URL earned, and an unchanged page comes back as outcome
|
|
105
107
|
* `unchanged` with `http_status` 304 and no body — a dated observation, not an absence.
|
|
106
108
|
*/
|
|
107
|
-
async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null, validators = null } = {}) {
|
|
109
|
+
async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null, validators = null, recovery = options.recovery??{},purpose = options.purpose??'discovery', signal = options.signal, deadlineAt = options.deadlineAt } = {}) {
|
|
108
110
|
const at = clock();
|
|
109
111
|
const base = { url: rawUrl, final_url: null, kind, http_status: null, robots: null,
|
|
110
112
|
html: null, body_truncated: false, fetched_at: at, reason: null, etag: null, last_modified: null };
|
|
@@ -112,21 +114,30 @@ export function createAcquisition(options = {}) {
|
|
|
112
114
|
const checked = validatePublicUrl(rawUrl);
|
|
113
115
|
if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
|
|
114
116
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
const consumed=[];
|
|
118
|
+
const acquireOne=async(routeKind,settings,provider=null,method='workers_http')=>{
|
|
119
|
+
try {
|
|
120
|
+
if (routeKind === 'direct') {
|
|
121
|
+
const { outcome, trace, html, validators: earned, linkHeader, bytes, capture } = await direct(checked.url, beforeFetch, { validators,...settings,alternate:provider,method });
|
|
122
|
+
const result={ ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
|
|
123
|
+
robots: trace.robots, redirects: trace.redirects, html, bytes, capture,
|
|
124
|
+
robotsHistory:trace.robotsHistory,sourceResponse:trace.sourceResponse,
|
|
120
125
|
etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
|
|
121
126
|
link_header: linkHeader ?? null,
|
|
122
127
|
// The verifier's reader refuses an oversized body outright, so a returned body was
|
|
123
128
|
// read to the end. Truncation reaches extraction from runtimes with their own caps.
|
|
124
129
|
body_truncated: false, render_ms: 0, proxy_bytes: 0 };
|
|
130
|
+
// Acquisition and parsing failures enter the same router. No fetched shell
|
|
131
|
+
// can silently become an empty publisher in context or the owned corpus.
|
|
132
|
+
if(result.outcome==='fetched')try{extractEdges(html,{pageUrl:trace.finalUrl,fetchKind:'direct'});}
|
|
133
|
+
catch(error){if(!(error instanceof ExtractionError))throw error;return {...result,outcome:classifyFailure(error.reason),reason:error.reason,html:null};}
|
|
134
|
+
return result;
|
|
125
135
|
}
|
|
126
|
-
const runtime = assertRuntime(
|
|
127
|
-
const result = await runtime.fetchPage(checked.url, { maxBytes,
|
|
128
|
-
|
|
136
|
+
const runtime = assertRuntime(routeKind);
|
|
137
|
+
const result = await runtime.fetchPage(checked.url, { maxBytes,...settings,beforeFetch });
|
|
138
|
+
const observed={ ...base,kind:routeKind,outcome: result.outcome ?? 'fetched', final_url: result.finalUrl ?? checked.url,
|
|
129
139
|
http_status: result.httpStatus ?? null, html: result.html ?? null,
|
|
140
|
+
positive_only:result.positiveOnly===true,robots:result.robots??null,robotsHistory:result.robotsHistory,sourceResponse:result.sourceResponse,
|
|
130
141
|
body_truncated: result.bodyTruncated === true, reason: result.reason ?? null,
|
|
131
142
|
// The units a runtime actually consumed. Rendering and proxy traffic are what make
|
|
132
143
|
// these paths expensive, so dropping them here would leave every metered run
|
|
@@ -134,13 +145,38 @@ export function createAcquisition(options = {}) {
|
|
|
134
145
|
// one. A direct fetch reports neither, which is correct: it spends neither.
|
|
135
146
|
render_ms: Number.isInteger(result.renderMs) ? result.renderMs : 0,
|
|
136
147
|
proxy_bytes: Number.isInteger(result.proxyBytes) ? result.proxyBytes : 0 };
|
|
137
|
-
|
|
148
|
+
consumed.push(observed);
|
|
149
|
+
if(observed.outcome==='fetched')try{extractEdges(observed.html??'',{pageUrl:observed.final_url,fetchKind:routeKind,bodyTruncated:observed.body_truncated||observed.positive_only});}
|
|
150
|
+
catch(error){if(!(error instanceof ExtractionError))throw error;return {...observed,outcome:classifyFailure(error.reason),reason:error.reason,html:null};}
|
|
151
|
+
return observed;
|
|
152
|
+
} catch (error) {
|
|
138
153
|
// Any error carrying its own reason keeps it, including the pacing hook's, so a
|
|
139
154
|
// slot we could not take is classified as deferrable rather than as a failure.
|
|
140
155
|
const reason = typeof error?.reason === 'string' ? error.reason : 'acquisition_error';
|
|
141
156
|
const outcome = error instanceof AcquisitionError && /not_configured|not_asserted|_invalid$/.test(reason)
|
|
142
157
|
? 'not_configured' : classifyFailure(reason);
|
|
143
|
-
|
|
158
|
+
const trace=error.acquisitionTrace??{};
|
|
159
|
+
return { ...base,kind:routeKind,outcome,reason,robots:trace.robots,robotsHistory:trace.robotsHistory,sourceResponse:trace.sourceResponse,
|
|
160
|
+
http_status:trace.httpStatus??null,final_url:trace.finalUrl??null};
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
try{
|
|
164
|
+
if(kind!=='direct')return await acquireOne(kind,{signal,deadlineAt,timeoutMs});
|
|
165
|
+
const provider=recovery.alternate;
|
|
166
|
+
const runtimeRoute=(runtimeKind,method)=>({capability:recovery.capabilities?.[method]??{available:runtimes[runtimeKind]?.publicFetchSafe===true&&typeof runtimes[runtimeKind]?.fetchPage==='function',reason:'unsupported'},
|
|
167
|
+
admit:recovery[`${runtimeKind}Admission`],execute:async settings=>[await acquireOne(runtimeKind,settings)]});
|
|
168
|
+
const [result]=await runRecovery({purpose,directMethod:options.directMethod??'workers_http',authorize:recovery.authorize,options:{signal,deadlineAt,timeoutMs},
|
|
169
|
+
directFailure:reason=>[{...base,outcome:'unavailable',reason}],
|
|
170
|
+
executeDirect:async settings=>[await acquireOne('direct',settings)],routes:{
|
|
171
|
+
node_http:{capability:recovery.capabilities?.node_http??{available:!!provider,reason:'unsupported'},receipts:provider?.receipts,
|
|
172
|
+
execute:provider?async settings=>[await acquireOne('direct',settings,provider,'node_http')]:undefined},
|
|
173
|
+
node_browser:runtimeRoute('rendered','node_browser'),proxy_http:runtimeRoute('proxied','proxy_http')}});
|
|
174
|
+
result.acquisition=recoverySummary(result.evidence?.acquisition);
|
|
175
|
+
result.fetches=result.acquisition?.attempts.length??1;
|
|
176
|
+
result.render_ms=consumed.reduce((sum,row)=>sum+(row.render_ms??0),0);result.proxy_bytes=consumed.reduce((sum,row)=>sum+(row.proxy_bytes??0),0);
|
|
177
|
+
return result;
|
|
178
|
+
}catch(error){
|
|
179
|
+
return {...base,outcome:classifyFailure(error.reason??'acquisition_error'),reason:error.reason??'acquisition_error'};
|
|
144
180
|
}
|
|
145
181
|
},
|
|
146
182
|
|
|
@@ -153,16 +189,16 @@ export function createAcquisition(options = {}) {
|
|
|
153
189
|
* The crawl loop never calls this; it exists for the freshness lanes (sitemap diffing,
|
|
154
190
|
* feed polling) that read publisher declarations rather than pages.
|
|
155
191
|
*/
|
|
156
|
-
async fetchDocument(rawUrl, { beforeFetch = null, validators = null } = {}) {
|
|
192
|
+
async fetchDocument(rawUrl, { beforeFetch = null, validators = null, signal = options.signal, deadlineAt = options.deadlineAt } = {}) {
|
|
157
193
|
const at = clock();
|
|
158
194
|
const base = { url: rawUrl, final_url: null, kind: 'direct', http_status: null,
|
|
159
195
|
body: null, fetched_at: at, reason: null, etag: null, last_modified: null };
|
|
160
196
|
const checked = validatePublicUrl(rawUrl);
|
|
161
197
|
if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
|
|
162
198
|
try {
|
|
163
|
-
const { outcome, trace, html, validators: earned, linkHeader } = await direct(checked.url, beforeFetch, { validators, document: true });
|
|
199
|
+
const { outcome, trace, html, validators: earned, linkHeader, bytes, capture } = await direct(checked.url, beforeFetch, { validators, document: true, signal, deadlineAt });
|
|
164
200
|
return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
|
|
165
|
-
body: outcome === 'fetched' ? html : null,
|
|
201
|
+
body: outcome === 'fetched' ? html : null, bytes, capture,
|
|
166
202
|
etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
|
|
167
203
|
link_header: linkHeader ?? null };
|
|
168
204
|
} catch (error) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { parseDocument, pageDirectives } from '../verifier/parser.js';
|
|
2
|
+
import { validatePublicUrl } from '../verifier/url.js';
|
|
3
|
+
import { frontierKey } from './frontier.js';
|
|
4
|
+
import { analyzeReadiness, isAccessChallenge } from '../verifier/readiness.js';
|
|
5
|
+
|
|
6
|
+
// Turns one fetched public HTML page into the outbound edges our corpus stores.
|
|
7
|
+
// Pure: no fetch, no persistence, no scheduling. The caller owns acquisition.
|
|
8
|
+
//
|
|
9
|
+
// This deliberately reuses the verifier's parser rather than adopting an audit crawler's.
|
|
10
|
+
// That parser already keeps every anchor/area occurrence instead of collapsing them by
|
|
11
|
+
// destination, keeps raw rel tokens rather than a nofollow boolean, and THROWS on its
|
|
12
|
+
// complexity limits instead of returning a short list. The last property is the one that
|
|
13
|
+
// matters most here: a page we could not fully read must never be recorded as a page with
|
|
14
|
+
// few links, because absence is what the whole product must not fake.
|
|
15
|
+
export const EXTRACTION_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
// A link carrying any of these is a hint, not an endorsement, so it does not pass equity.
|
|
18
|
+
const UNFOLLOWED_REL = new Set(['nofollow', 'sponsored', 'ugc']);
|
|
19
|
+
|
|
20
|
+
export class ExtractionError extends Error {
|
|
21
|
+
constructor(reason) { super(reason); this.name = 'ExtractionError'; this.reason = reason; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Same host, or one host is a subdomain of the other. */
|
|
25
|
+
function relatedHost(host, other) {
|
|
26
|
+
return host === other || host.endsWith(`.${other}`) || other.endsWith(`.${host}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param html Decoded response body. The caller must say if it was cut short.
|
|
31
|
+
* @param pageUrl Canonical URL of the page the body came from, after redirects.
|
|
32
|
+
* @param options.fetchKind How the body was acquired: direct, rendered or proxied.
|
|
33
|
+
* @param options.bodyTruncated True when the byte budget stopped the read.
|
|
34
|
+
* @param options.xRobotsTag Raw X-Robots-Tag response header, if any.
|
|
35
|
+
* @param options.internalHosts Hosts to treat as the source's own. Defaults to the source
|
|
36
|
+
* host and its subdomains. Pass the site's real host list
|
|
37
|
+
* when siblings such as blog. and www. must count as internal;
|
|
38
|
+
* without a public suffix list this cannot be inferred.
|
|
39
|
+
* @throws ExtractionError with `.reason` when the page could not be read completely enough
|
|
40
|
+
* to enumerate its links. Callers record an unknown page, never an empty one.
|
|
41
|
+
*/
|
|
42
|
+
export function extractEdges(html, { pageUrl, fetchKind, bodyTruncated = false, xRobotsTag = null, internalHosts } = {}) {
|
|
43
|
+
if (!['direct', 'rendered', 'proxied'].includes(fetchKind)) throw new ExtractionError('unsupported_fetch_kind');
|
|
44
|
+
const page = validatePublicUrl(pageUrl);
|
|
45
|
+
if (!page.valid) throw new ExtractionError(`source_${page.reason}`);
|
|
46
|
+
|
|
47
|
+
let document;
|
|
48
|
+
// Complexity limits reject the page rather than returning part of it. Re-thrown as an
|
|
49
|
+
// ExtractionError so a caller cannot mistake a refusal for a result.
|
|
50
|
+
try { document = parseDocument(html, page.url); }
|
|
51
|
+
catch (error) { throw new ExtractionError(error.reason ?? 'unreadable_document'); }
|
|
52
|
+
|
|
53
|
+
// A page whose links we could not have seen must not be stored as a page with few links.
|
|
54
|
+
// These are the verifier's own "cannot conclude absence" rules, which matter even more
|
|
55
|
+
// here: a plain-fetch crawler that records a JavaScript-rendered publisher as link-free
|
|
56
|
+
// poisons the corpus silently, and that is the single most common false-negative in this
|
|
57
|
+
// category. The rules are asymmetric on purpose — a real article that happens to carry a
|
|
58
|
+
// login form still extracts, because it produced links.
|
|
59
|
+
const { readiness, managedClientChallenge } = analyzeReadiness({ html, parsed: document });
|
|
60
|
+
if (isAccessChallenge(document, { managedClientChallenge })) throw new ExtractionError('access_challenge');
|
|
61
|
+
if (!document.links.length) {
|
|
62
|
+
if (document.meta.some(item => item.name === 'refresh')) throw new ExtractionError('navigation_requires_browser');
|
|
63
|
+
if (readiness.possibleLoginWall) throw new ExtractionError('possible_login_wall');
|
|
64
|
+
if (document.hasScript && document.visibleText.length < 80) throw new ExtractionError('possible_render_required');
|
|
65
|
+
}
|
|
66
|
+
// Navigation links around an empty application root do not make its content complete.
|
|
67
|
+
if(readiness.requiresRender)throw new ExtractionError('possible_render_required');
|
|
68
|
+
|
|
69
|
+
const directives = pageDirectives(document.meta, xRobotsTag);
|
|
70
|
+
const owned = internalHosts?.length
|
|
71
|
+
? internalHosts.map(host => host.toLowerCase())
|
|
72
|
+
: [page.hostname];
|
|
73
|
+
|
|
74
|
+
// Group occurrences by canonical destination. Discovery reports that an edge exists and
|
|
75
|
+
// how often; per-occurrence anchor, rel and evidence belong to a verification check,
|
|
76
|
+
// which the verifier already produces on demand for a selected candidate.
|
|
77
|
+
const byTarget = new Map();
|
|
78
|
+
let outlinks = 0, externalOutlinks = 0;
|
|
79
|
+
for (const link of document.links) {
|
|
80
|
+
const target = validatePublicUrl(link.targetUrl);
|
|
81
|
+
if (!target.valid) continue;
|
|
82
|
+
outlinks++;
|
|
83
|
+
const internal = owned.some(host => relatedHost(target.hostname, host));
|
|
84
|
+
if (!internal) externalOutlinks++;
|
|
85
|
+
const existing = byTarget.get(target.url);
|
|
86
|
+
const followed = !directives.nofollow && !link.rel.some(token => UNFOLLOWED_REL.has(token));
|
|
87
|
+
if (existing) {
|
|
88
|
+
existing.links_count++;
|
|
89
|
+
// Any followed occurrence makes the edge followed: a nofollow copy in a comment does
|
|
90
|
+
// not undo a followed link in the body. `rel` stays the first occurrence's tokens,
|
|
91
|
+
// so `dofollow` is NOT a function of the `rel` reported beside it.
|
|
92
|
+
existing.dofollow ||= followed;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
byTarget.set(target.url, {
|
|
96
|
+
target_url: target.url, target_hostname: target.hostname, internal,
|
|
97
|
+
anchor: link.anchor || null, rel: link.rel.length ? link.rel : null, dofollow: followed,
|
|
98
|
+
links_count: 1, context: link.context || null, locator: link.locator,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
v: EXTRACTION_VERSION,
|
|
104
|
+
source_url: page.url, source_hostname: page.hostname, fetch_kind: fetchKind,
|
|
105
|
+
// A body the byte budget cut short, or a document with unfinished markup, was not read
|
|
106
|
+
// to the end. The contract refuses `complete_for_query` coverage on such a page.
|
|
107
|
+
extraction_complete: !bodyTruncated && !document.incomplete,
|
|
108
|
+
page_nofollow: directives.nofollow, page_noindex: directives.noindex,
|
|
109
|
+
// Non-null when the page declares a DIFFERENT URL as canonical, i.e. it is a duplicate
|
|
110
|
+
// of that one. Recorded and surfaced, never used to silently drop the page: cross-domain
|
|
111
|
+
// and plain wrong canonicals are common, and discarding them would lose real links.
|
|
112
|
+
// Compared through the frontier key, so a canonical differing only by a trailing slash,
|
|
113
|
+
// an index filename or a tracking parameter is the SAME page, not a duplicate of another.
|
|
114
|
+
// Measured against real publishers, raw string comparison flagged 11 of 14 pages as
|
|
115
|
+
// duplicates when almost all of them were declaring themselves.
|
|
116
|
+
canonical_url: document.canonical && frontierKey(document.canonical) !== frontierKey(page.url)
|
|
117
|
+
? document.canonical : null,
|
|
118
|
+
source_outlink_count: outlinks, source_external_outlink_count: externalOutlinks,
|
|
119
|
+
title: document.title || null,
|
|
120
|
+
edges: [...byTarget.values()],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Shapes one extracted edge as a discovery candidate for the owned corpus. The caller
|
|
126
|
+
* supplies run lineage and the crawl clocks; nothing here invents a date or a check.
|
|
127
|
+
*/
|
|
128
|
+
export function edgeToCandidate(edge, page, { runId, workspaceId, projectId, candidateId, taskId, retrievedAt, firstSeen = null, prevSeen = null, lastSeen = null }) {
|
|
129
|
+
return {
|
|
130
|
+
v: 1, id: candidateId, workspace_id: workspaceId, project_id: projectId, discovery_run_id: runId,
|
|
131
|
+
source_url: page.source_url, target_url: edge.target_url,
|
|
132
|
+
provider: 'linktrail_corpus', data_mode: 'owned_corpus',
|
|
133
|
+
provider_task_id: taskId, provider_retrieved_at: retrievedAt,
|
|
134
|
+
provider_first_seen: firstSeen, provider_prev_seen: prevSeen, provider_last_seen: lastSeen,
|
|
135
|
+
// A crawl that saw the link says nothing about loss or breakage, and our own crawl
|
|
136
|
+
// clocks are still upstream data rather than a check against a customer expectation.
|
|
137
|
+
provider_status: { is_lost: null, is_broken: null, is_new: null },
|
|
138
|
+
anchor: edge.anchor, rel: edge.rel, dofollow: edge.dofollow, link_type: 'anchor',
|
|
139
|
+
source_http_status: null, target_http_status: null, links_count: edge.links_count,
|
|
140
|
+
provider_metrics: { linktrail_corpus: {
|
|
141
|
+
source_outlink_count: page.source_outlink_count,
|
|
142
|
+
source_external_outlink_count: page.source_external_outlink_count,
|
|
143
|
+
fetch_kind: page.fetch_kind, extraction_complete: page.extraction_complete,
|
|
144
|
+
} },
|
|
145
|
+
verification_status: 'not_checked', verified_at: null, observation_id: null, evidence_id: null,
|
|
146
|
+
};
|
|
147
|
+
}
|