eduevidence 5.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/LICENSE +21 -0
- package/README.md +506 -0
- package/README.zh-CN.md +434 -0
- package/SKILL.md +195 -0
- package/bin/eduevidence.js +127 -0
- package/domains/education/manifest.json +183 -0
- package/domains/education/outcome_taxonomy.json +127 -0
- package/domains/manifest.json +26 -0
- package/domains/policy/frame.schema.json +234 -0
- package/domains/policy/manifest.json +10 -0
- package/domains/policy/methodology_checklist.json +109 -0
- package/domains/policy/outcome_taxonomy.json +53 -0
- package/domains/policy/references/causal-identification.md +45 -0
- package/domains/policy/references/cost-evidence.md +44 -0
- package/domains/policy/references/equity.md +42 -0
- package/domains/policy/references/evidence-hierarchy.md +41 -0
- package/domains/policy/references/implementation-evidence.md +47 -0
- package/eduevidence_cli.py +26 -0
- package/engine/__init__.py +11 -0
- package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/bias.cpython-312.pyc +0 -0
- package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
- package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
- package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
- package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
- package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
- package/engine/__pycache__/events.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
- package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
- package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
- package/engine/__pycache__/ids.cpython-312.pyc +0 -0
- package/engine/__pycache__/library.cpython-312.pyc +0 -0
- package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
- package/engine/__pycache__/living.cpython-312.pyc +0 -0
- package/engine/__pycache__/log.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/migration.cpython-312.pyc +0 -0
- package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
- package/engine/__pycache__/paths.cpython-312.pyc +0 -0
- package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
- package/engine/__pycache__/planner.cpython-312.pyc +0 -0
- package/engine/__pycache__/project.cpython-312.pyc +0 -0
- package/engine/__pycache__/projections.cpython-312.pyc +0 -0
- package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
- package/engine/__pycache__/run.cpython-312.pyc +0 -0
- package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
- package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
- package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
- package/engine/__pycache__/update.cpython-312.pyc +0 -0
- package/engine/__pycache__/versions.cpython-312.pyc +0 -0
- package/engine/analysis.py +308 -0
- package/engine/bias.py +178 -0
- package/engine/briefs.py +106 -0
- package/engine/capabilities.py +99 -0
- package/engine/citation_check.py +192 -0
- package/engine/contracts.py +117 -0
- package/engine/datasets.py +165 -0
- package/engine/events.py +67 -0
- package/engine/evidence_graph.py +571 -0
- package/engine/evidence_review.py +88 -0
- package/engine/evidencecore.py +182 -0
- package/engine/gap_lens.py +132 -0
- package/engine/gaps.py +169 -0
- package/engine/graph_store.py +335 -0
- package/engine/graph_validate.py +87 -0
- package/engine/ids.py +77 -0
- package/engine/library.py +268 -0
- package/engine/library_builtin.py +301 -0
- package/engine/living.py +671 -0
- package/engine/log.py +39 -0
- package/engine/meta_analysis.py +333 -0
- package/engine/meta_synthesis.py +111 -0
- package/engine/migration.py +397 -0
- package/engine/mode_router.py +72 -0
- package/engine/paths.py +15 -0
- package/engine/pilot.py +368 -0
- package/engine/planner.py +126 -0
- package/engine/project.py +118 -0
- package/engine/projections.py +240 -0
- package/engine/robustness.py +109 -0
- package/engine/run.py +85 -0
- package/engine/semantics.py +135 -0
- package/engine/study_design.py +87 -0
- package/engine/synthesis.py +187 -0
- package/engine/tribunal.py +408 -0
- package/engine/update.py +113 -0
- package/engine/versions.py +12 -0
- package/install.sh +510 -0
- package/integrations/__init__.py +1 -0
- package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
- package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
- package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
- package/integrations/agent_mcp.py +856 -0
- package/integrations/smart_web_fetch.py +59 -0
- package/package.json +50 -0
- package/pyproject.toml +55 -0
- package/references/applicability-policy.md +88 -0
- package/references/education-framing.md +132 -0
- package/references/effect_size_formulas.md +35 -0
- package/references/evaluation-design.md +111 -0
- package/references/evidence-quality.md +79 -0
- package/references/grade_framework.md +29 -0
- package/references/intervention-design.md +98 -0
- package/references/methodology-audit.md +103 -0
- package/references/outcome-taxonomy.md +106 -0
- package/references/retrieval-protocol.md +142 -0
- package/references/skeptic-protocol.md +93 -0
- package/references/social_science_pitfalls.md +48 -0
- package/references/source-validity.md +140 -0
- package/references/tribunal-policy.md +112 -0
- package/references/wwc_standards.md +29 -0
- package/retrieval/__init__.py +1 -0
- package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
- package/retrieval/corpus_store.py +181 -0
- package/retrieval/dedupe.py +127 -0
- package/retrieval/failures.py +90 -0
- package/retrieval/fetch.py +435 -0
- package/retrieval/search.py +493 -0
- package/retrieval/source.py +160 -0
- package/retrieval/validate.py +257 -0
- package/schemas/agent-mcp-approval.schema.json +57 -0
- package/schemas/chart-spec.schema.json +88 -0
- package/schemas/cross-model-review.schema.json +28 -0
- package/schemas/education-frame.schema.json +106 -0
- package/schemas/evaluation.schema.json +35 -0
- package/schemas/evidence.schema.json +81 -0
- package/schemas/fetch-result.schema.json +119 -0
- package/schemas/intervention.schema.json +46 -0
- package/schemas/methodology.schema.json +102 -0
- package/schemas/report-result.schema.json +381 -0
- package/schemas/report-spec.schema.json +130 -0
- package/schemas/source.schema.json +311 -0
- package/schemas/v2/analysis-plan.schema.json +28 -0
- package/schemas/v2/analysis-run.schema.json +33 -0
- package/schemas/v2/claim.schema.json +26 -0
- package/schemas/v2/dataset-asset.schema.json +40 -0
- package/schemas/v2/decision-snapshot.schema.json +53 -0
- package/schemas/v2/evidence-link.schema.json +38 -0
- package/schemas/v2/finding.schema.json +47 -0
- package/schemas/v2/graph-revision.schema.json +30 -0
- package/schemas/v2/knowledge-gap.schema.json +40 -0
- package/schemas/v2/methodology-audit.schema.json +30 -0
- package/schemas/v2/outcome.schema.json +18 -0
- package/schemas/v2/project.schema.json +31 -0
- package/schemas/v2/research-intent.schema.json +24 -0
- package/schemas/v2/run.schema.json +43 -0
- package/schemas/v2/source.schema.json +24 -0
- package/schemas/v2/study-design.schema.json +67 -0
- package/schemas/v2/study.schema.json +37 -0
- package/schemas/v3/pilot-outcome.schema.json +132 -0
- package/schemas/v3/run-manifest.schema.json +193 -0
- package/schemas/v3/synthesis.schema.json +86 -0
- package/schemas/v4/drift-report.schema.json +66 -0
- package/schemas/v4/evidence-library.schema.json +42 -0
- package/schemas/v4/living-subscription.schema.json +55 -0
- package/schemas/v4/meta-analysis.schema.json +152 -0
- package/schemas/verdict.schema.json +56 -0
- package/scripts/__init__.py +0 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
- package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
- package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
- package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
- package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
- package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
- package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
- package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
- package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
- package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
- package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
- package/scripts/audit_dois.py +292 -0
- package/scripts/bake_pack.sh +37 -0
- package/scripts/benchmark.py +183 -0
- package/scripts/benchmark_evaluator.py +371 -0
- package/scripts/benchmark_judge.py +535 -0
- package/scripts/benchmark_routing.py +120 -0
- package/scripts/benchmark_v2.py +304 -0
- package/scripts/benchmark_v3.py +552 -0
- package/scripts/build_esl_artifacts.py +1921 -0
- package/scripts/build_evidence_library.py +307 -0
- package/scripts/build_killer_demo.py +295 -0
- package/scripts/build_result.py +311 -0
- package/scripts/check_version_consistency.py +96 -0
- package/scripts/citation_check.py +123 -0
- package/scripts/claim_audit.py +157 -0
- package/scripts/complexity_gate.py +180 -0
- package/scripts/compute_confidence.py +176 -0
- package/scripts/dashboard_server.py +536 -0
- package/scripts/did_regression.py +315 -0
- package/scripts/effect_calculator.py +99 -0
- package/scripts/enrich_projects_human_and_lieflat.py +315 -0
- package/scripts/evidence_matrix.py +129 -0
- package/scripts/evidence_score.py +234 -0
- package/scripts/evidence_semantics.py +87 -0
- package/scripts/fetch_benchmark.py +177 -0
- package/scripts/generate_metrics.py +99 -0
- package/scripts/generate_new_projects.py +686 -0
- package/scripts/generate_promo.py +192 -0
- package/scripts/lint_report_layout.py +182 -0
- package/scripts/orchestrator.py +1456 -0
- package/scripts/pre_verdict_gate.py +513 -0
- package/scripts/quickstart.py +121 -0
- package/scripts/rebake_all_5themes.py +88 -0
- package/scripts/recompute_demo_quality.py +205 -0
- package/scripts/render_report.py +270 -0
- package/scripts/render_report_html.py +356 -0
- package/scripts/retraction_watch.py +110 -0
- package/scripts/run_workspace.py +337 -0
- package/scripts/serve_web.py +54 -0
- package/scripts/skill_lint.py +150 -0
- package/scripts/startup_probe.py +265 -0
- package/scripts/sync_killer_demo_report.py +270 -0
- package/scripts/test_adversarial_empirical.py +541 -0
- package/scripts/validate_schema.py +256 -0
- package/skill/agents/education-planner.md +80 -0
- package/skill/agents/evaluation-designer.md +74 -0
- package/skill/agents/evidence-analyst.md +106 -0
- package/skill/agents/evidence-judge.md +111 -0
- package/skill/agents/evidence-retriever.md +80 -0
- package/skill/agents/intervention-designer.md +82 -0
- package/skill/agents/method-reviewer.md +104 -0
- package/skill/agents/skeptic.md +89 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
- package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
- package/skill/sub-skills/data-analysis/SKILL.md +23 -0
- package/skill/sub-skills/ethics-review/SKILL.md +25 -0
- package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
- package/skill/sub-skills/evidence-review/SKILL.md +18 -0
- package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
- package/skill/sub-skills/literature-review/SKILL.md +21 -0
- package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
- package/skill/sub-skills/report-generation/SKILL.md +51 -0
- package/skill/sub-skills/research-planning/SKILL.md +21 -0
- package/skill/sub-skills/study-design/SKILL.md +16 -0
- package/skill/task-briefs/adjudicate.md +17 -0
- package/skill/task-briefs/audit.md +15 -0
- package/skill/task-briefs/challenge.md +15 -0
- package/skill/task-briefs/evaluate.md +13 -0
- package/skill/task-briefs/extract.md +16 -0
- package/skill/task-briefs/frame.md +17 -0
- package/skill/task-briefs/intervene.md +14 -0
- package/skill/task-briefs/present.md +16 -0
- package/skill/task-briefs/retrieve.md +15 -0
- package/visualization/eduevidence-report/assets/base.css +337 -0
- package/visualization/eduevidence-report/motion/motion.css +157 -0
- package/visualization/eduevidence-report/motion/motion.js +107 -0
- package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
- package/visualization/eduevidence-report/references/component-catalog.md +145 -0
- package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
- package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
- package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
- package/visualization/eduevidence-report/references/motion-system.md +31 -0
- package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
- package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
- package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
- package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
- package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
- package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
- package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
- package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
- package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
- package/visualization/eduevidence-report/themes/academic.css +94 -0
- package/visualization/eduevidence-report/themes/claude.css +96 -0
- package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
- package/visualization/eduevidence-report/themes/datalab.css +151 -0
- package/visualization/eduevidence-report/themes/presentation.css +140 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""failures.py — Failure states & recovery routing (Smart Web Fetch v3 §11-12).
|
|
3
|
+
|
|
4
|
+
Search failure and fetch failure are different problems and must be told apart:
|
|
5
|
+
|
|
6
|
+
SEARCH_NO_RESULT -> 重新搜索 / 换 discovery provider
|
|
7
|
+
SEARCH_LOW_QUALITY -> 放宽检索式 / 降级接受低权威来源
|
|
8
|
+
FETCH_FAILED -> 换 fetch provider / 回 Discovery 找替代来源
|
|
9
|
+
FETCH_PARTIAL -> 规则确认后才可进入 Evidence Extraction
|
|
10
|
+
SOURCE_INVALID -> 丢弃并找替代
|
|
11
|
+
SOURCE_DUPLICATE -> 去重合并,不算独立证据
|
|
12
|
+
|
|
13
|
+
Recovery principle (v3 §12): never infinite-retry the same fetch. After the
|
|
14
|
+
full degradation chain fails, return to Discovery and find an alternate source
|
|
15
|
+
for the same paper/fact.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
FAILURE_STATES = (
|
|
22
|
+
"SEARCH_NO_RESULT",
|
|
23
|
+
"SEARCH_LOW_QUALITY",
|
|
24
|
+
"FETCH_FAILED",
|
|
25
|
+
"FETCH_PARTIAL",
|
|
26
|
+
"SOURCE_INVALID",
|
|
27
|
+
"SOURCE_DUPLICATE",
|
|
28
|
+
"UNSUPPORTED_CLAIM",
|
|
29
|
+
"CONFLICT_UNRESOLVED",
|
|
30
|
+
"SCOPE_MISMATCH",
|
|
31
|
+
"METHODOLOGY_TOO_WEAK",
|
|
32
|
+
"INSUFFICIENT_EVIDENCE",
|
|
33
|
+
"AGENT_MCP_UNAVAILABLE",
|
|
34
|
+
"REPORT_INVALID",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
RECOVERY_ACTION = {
|
|
38
|
+
"SEARCH_NO_RESULT": "rerun_search_with_broader_terms",
|
|
39
|
+
"SEARCH_LOW_QUALITY": "widen_query_or_accept_lower_authority_tier",
|
|
40
|
+
"FETCH_FAILED": "alternate_fetch_provider_then_alternate_source",
|
|
41
|
+
"FETCH_PARTIAL": "rule_confirm_or_human_confirm_before_extraction",
|
|
42
|
+
"SOURCE_INVALID": "discard_and_find_alternate_source",
|
|
43
|
+
"SOURCE_DUPLICATE": "merge_keep_highest_authority",
|
|
44
|
+
"UNSUPPORTED_CLAIM": "downgrade_claim_or_drop",
|
|
45
|
+
"CONFLICT_UNRESOLVED": "stay_uncertain_do_not_force_adjudication",
|
|
46
|
+
"SCOPE_MISMATCH": "shrink_conclusion_scope",
|
|
47
|
+
"METHODOLOGY_TOO_WEAK": "do_not_use_as_support",
|
|
48
|
+
"INSUFFICIENT_EVIDENCE": "mark_insufficient_evidence",
|
|
49
|
+
"AGENT_MCP_UNAVAILABLE": "degrade_to_platform_native_mode",
|
|
50
|
+
"REPORT_INVALID": "block_publish_rerun_render",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classify_fetch(fetch_result: dict[str, Any]) -> str:
|
|
55
|
+
"""Map a fetch result to the canonical failure/state token."""
|
|
56
|
+
status = fetch_result.get("fetch_status", "FETCH_FAILED")
|
|
57
|
+
if status == "FETCH_VALID":
|
|
58
|
+
validation = fetch_result.get("validation", {})
|
|
59
|
+
if validation.get("passed"):
|
|
60
|
+
return "FETCH_VALID"
|
|
61
|
+
return "FETCH_PARTIAL"
|
|
62
|
+
if status == "FETCH_PARTIAL":
|
|
63
|
+
return "FETCH_PARTIAL"
|
|
64
|
+
return "FETCH_FAILED"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def recovery_plan(state: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Return the recovery plan for a failure state (no infinite retry)."""
|
|
69
|
+
if state not in RECOVERY_ACTION:
|
|
70
|
+
raise ValueError(f"unknown state {state!r}; known: {sorted(RECOVERY_ACTION)}")
|
|
71
|
+
plan: dict[str, Any] = {
|
|
72
|
+
"state": state,
|
|
73
|
+
"action": RECOVERY_ACTION[state],
|
|
74
|
+
"retry": False,
|
|
75
|
+
"note": "",
|
|
76
|
+
}
|
|
77
|
+
ctx = context or {}
|
|
78
|
+
if state == "FETCH_FAILED":
|
|
79
|
+
plan["note"] = (
|
|
80
|
+
f"degradation chain exhausted ({ctx.get('fallback_chain', [])}); "
|
|
81
|
+
"do not retry the same URL — return to Discovery for an alternate source "
|
|
82
|
+
"of the same paper/fact."
|
|
83
|
+
)
|
|
84
|
+
elif state == "FETCH_PARTIAL":
|
|
85
|
+
plan["note"] = "content partially readable; require rule/human confirmation before Evidence Extraction (v3 §8)."
|
|
86
|
+
elif state == "SEARCH_NO_RESULT":
|
|
87
|
+
plan["note"] = "no results for the query; broaden terms or switch discovery provider."
|
|
88
|
+
elif state == "SOURCE_DUPLICATE":
|
|
89
|
+
plan["note"] = "same paper behind mirror URL; merge and keep the highest-authority entry."
|
|
90
|
+
return plan
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""fetch.py — Fetch Reliability Layer (Smart Web Fetch 方案 v3 §2-8).
|
|
3
|
+
|
|
4
|
+
Fetch only, never search. Responsibility: given a known URL, reliably read the
|
|
5
|
+
content and clean it into Markdown/text. Degradation chain (v3 §3 / §12):
|
|
6
|
+
|
|
7
|
+
native (builtin)
|
|
8
|
+
-> jina_reader
|
|
9
|
+
-> defuddle (local HTML -> main-text extraction, no third party)
|
|
10
|
+
-> markdown_new
|
|
11
|
+
-> raw_html
|
|
12
|
+
-> FETCH_FAILED
|
|
13
|
+
|
|
14
|
+
Every provider attempt is validated immediately (v3 §7): only
|
|
15
|
+
validation.passed=True ends the chain. HTTP 200 with a captcha/login/error
|
|
16
|
+
page or a too-short body continues to the next provider. Each attempt records
|
|
17
|
+
Fetch Provenance (original_url preserved, the real resp.geturl() as
|
|
18
|
+
resolved_url, provider tracked). Only FETCH_VALID (or rule-confirmed
|
|
19
|
+
FETCH_PARTIAL) may enter Evidence Extraction; FETCH_FAILED must never let the
|
|
20
|
+
model guess content from search snippets (v3 §8).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from urllib.parse import urlparse
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from html.parser import HTMLParser
|
|
33
|
+
from typing import Any, Callable
|
|
34
|
+
|
|
35
|
+
from retrieval.validate import (
|
|
36
|
+
is_private_url,
|
|
37
|
+
resolves_to_private,
|
|
38
|
+
validate_fetch_result,
|
|
39
|
+
)
|
|
40
|
+
from engine.log import get_log
|
|
41
|
+
|
|
42
|
+
log = get_log("fetch")
|
|
43
|
+
|
|
44
|
+
FETCH_PROVIDERS = ("builtin", "jina_reader", "defuddle", "markdown_new", "raw_html")
|
|
45
|
+
JINA_READER_PREFIX = "https://r.jina.ai/"
|
|
46
|
+
MARKDOWN_NEW_PREFIX = "https://markdown.new/"
|
|
47
|
+
MAX_REDIRECTS = 5
|
|
48
|
+
MAX_BODY_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
49
|
+
USER_AGENT = "EduEvidence/1.0 (+evidence)"
|
|
50
|
+
|
|
51
|
+
# Providers that fetch the original URL directly (locally). A private-network
|
|
52
|
+
# resolution on these aborts the whole chain — third-party providers must
|
|
53
|
+
# never receive private content.
|
|
54
|
+
LOCAL_PROVIDERS = ("builtin", "defuddle", "raw_html")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _BodyTooLarge(Exception):
|
|
58
|
+
"""Raised when a fetch response exceeds MAX_BODY_BYTES."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _MaxRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
62
|
+
"""Redirect handler that aborts after max_redirects hops (default 5)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, max_redirects: int = MAX_REDIRECTS):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.max_redirects = max_redirects
|
|
67
|
+
|
|
68
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
69
|
+
hops = getattr(req, "_redirect_count", 0)
|
|
70
|
+
if hops >= self.max_redirects:
|
|
71
|
+
raise urllib.error.HTTPError(
|
|
72
|
+
req.full_url, code, f"redirect limit ({self.max_redirects}) exceeded", headers, fp
|
|
73
|
+
)
|
|
74
|
+
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
75
|
+
if new_req is not None:
|
|
76
|
+
new_req._redirect_count = hops + 1 # type: ignore[attr-defined]
|
|
77
|
+
return new_req
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _http_get(
|
|
81
|
+
url: str,
|
|
82
|
+
timeout: int = 20,
|
|
83
|
+
max_redirects: int = MAX_REDIRECTS,
|
|
84
|
+
max_bytes: int = MAX_BODY_BYTES,
|
|
85
|
+
) -> tuple[int, str, str]:
|
|
86
|
+
"""GET url; returns (status, body, resolved_url=resp.geturl()).
|
|
87
|
+
|
|
88
|
+
Raises URLError / HTTPError / OSError / ValueError on network problems and
|
|
89
|
+
_BodyTooLarge when the response exceeds max_bytes. Redirects are limited
|
|
90
|
+
to max_redirects hops.
|
|
91
|
+
"""
|
|
92
|
+
opener = urllib.request.build_opener(_MaxRedirectHandler(max_redirects))
|
|
93
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
94
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
95
|
+
body = resp.read(max_bytes + 1)
|
|
96
|
+
if len(body) > max_bytes:
|
|
97
|
+
raise _BodyTooLarge(f"response body exceeds {max_bytes} bytes")
|
|
98
|
+
return resp.status, body.decode("utf-8", errors="replace"), resp.geturl()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _hash(content: str) -> str:
|
|
102
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------- defuddle
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_SKIP_TAGS = {
|
|
109
|
+
"script", "style", "nav", "header", "footer", "aside", "iframe",
|
|
110
|
+
"noscript", "svg", "form", "button", "select", "template",
|
|
111
|
+
}
|
|
112
|
+
_MAIN_TAGS = {"main", "article"}
|
|
113
|
+
_BLOCK_TAGS = {
|
|
114
|
+
"p", "div", "section", "article", "main", "blockquote", "pre",
|
|
115
|
+
"li", "tr", "ul", "ol", "table", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _MainTextExtractor(HTMLParser):
|
|
120
|
+
"""html.parser-based main-content extraction (the local 'defuddle')."""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
super().__init__(convert_charrefs=True)
|
|
124
|
+
self._skip_depth = 0
|
|
125
|
+
self._main_depth = 0
|
|
126
|
+
self._in_main = False
|
|
127
|
+
self._main_parts: list[str] = []
|
|
128
|
+
self._fallback_parts: list[str] = []
|
|
129
|
+
|
|
130
|
+
def _target(self) -> list[str]:
|
|
131
|
+
return self._main_parts if self._in_main else self._fallback_parts
|
|
132
|
+
|
|
133
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
134
|
+
tag = tag.lower()
|
|
135
|
+
if tag in _SKIP_TAGS:
|
|
136
|
+
self._skip_depth += 1
|
|
137
|
+
return
|
|
138
|
+
if self._skip_depth:
|
|
139
|
+
return
|
|
140
|
+
if tag in _MAIN_TAGS:
|
|
141
|
+
if self._main_depth == 0:
|
|
142
|
+
self._in_main = True
|
|
143
|
+
self._main_depth += 1
|
|
144
|
+
if tag in _BLOCK_TAGS:
|
|
145
|
+
self._target().append("\n")
|
|
146
|
+
|
|
147
|
+
def handle_endtag(self, tag: str) -> None:
|
|
148
|
+
tag = tag.lower()
|
|
149
|
+
if tag in _SKIP_TAGS:
|
|
150
|
+
self._skip_depth = max(0, self._skip_depth - 1)
|
|
151
|
+
return
|
|
152
|
+
if self._skip_depth:
|
|
153
|
+
return
|
|
154
|
+
if tag in _MAIN_TAGS:
|
|
155
|
+
self._main_depth = max(0, self._main_depth - 1)
|
|
156
|
+
if self._main_depth == 0:
|
|
157
|
+
self._in_main = False
|
|
158
|
+
if tag in _BLOCK_TAGS:
|
|
159
|
+
self._target().append("\n")
|
|
160
|
+
|
|
161
|
+
def handle_data(self, data: str) -> None:
|
|
162
|
+
if self._skip_depth:
|
|
163
|
+
return
|
|
164
|
+
self._target().append(data)
|
|
165
|
+
|
|
166
|
+
def result(self) -> str:
|
|
167
|
+
parts = self._main_parts if self._main_parts else self._fallback_parts
|
|
168
|
+
text = "".join(parts)
|
|
169
|
+
text = re.sub(r"[ \t\r\f\v]+", " ", text)
|
|
170
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
171
|
+
return text.strip()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def extract_main_text(html: str) -> str:
|
|
175
|
+
"""Local HTML -> readable-text cleaning (defuddle provider).
|
|
176
|
+
|
|
177
|
+
Drops script/style/nav/header/footer/aside etc. and prefers the text inside
|
|
178
|
+
<main>/<article> when present; falls back to the full cleaned body.
|
|
179
|
+
"""
|
|
180
|
+
parser = _MainTextExtractor()
|
|
181
|
+
try:
|
|
182
|
+
parser.feed(html)
|
|
183
|
+
parser.close()
|
|
184
|
+
except Exception:
|
|
185
|
+
# malformed HTML must never crash the degradation chain
|
|
186
|
+
return re.sub(r"<[^>]+>", " ", html)
|
|
187
|
+
return parser.result()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------- providers
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fetch_builtin(url: str, timeout: int) -> tuple[int, str, str]:
|
|
194
|
+
return _http_get(url, timeout=timeout)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _fetch_jina_reader(url: str, timeout: int) -> tuple[int, str, str]:
|
|
198
|
+
return _http_get(JINA_READER_PREFIX + url, timeout=timeout)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _fetch_defuddle(url: str, timeout: int) -> tuple[int, str, str]:
|
|
202
|
+
return _http_get(url, timeout=timeout)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _fetch_markdown_new(url: str, timeout: int) -> tuple[int, str, str]:
|
|
206
|
+
return _http_get(MARKDOWN_NEW_PREFIX + url, timeout=timeout)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _fetch_raw_html(url: str, timeout: int) -> tuple[int, str, str]:
|
|
210
|
+
return _http_get(url, timeout=timeout)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_PROVIDER_FETCHERS: dict[str, Callable[[str, int], tuple[int, str, str]]] = {
|
|
214
|
+
"builtin": _fetch_builtin,
|
|
215
|
+
"jina_reader": _fetch_jina_reader,
|
|
216
|
+
"defuddle": _fetch_defuddle,
|
|
217
|
+
"markdown_new": _fetch_markdown_new,
|
|
218
|
+
"raw_html": _fetch_raw_html,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _clean_for_provider(provider: str, body: str) -> str:
|
|
223
|
+
if provider in ("builtin", "raw_html"):
|
|
224
|
+
return _strip_html_to_text(body)
|
|
225
|
+
if provider == "defuddle":
|
|
226
|
+
return extract_main_text(body)
|
|
227
|
+
return body.strip() # jina_reader / markdown_new already return text
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _strip_html_to_text(html: str) -> str:
|
|
231
|
+
"""Minimal HTML noise reduction: drop scripts/styles/nav, keep text."""
|
|
232
|
+
html = re.sub(r"(?is)<(script|style|nav|header|footer|aside)[^>]*>.*?</\1>", " ", html)
|
|
233
|
+
html = re.sub(r"(?is)<[^>]+>", " ", html)
|
|
234
|
+
html = re.sub(r"\s+", " ", html)
|
|
235
|
+
return html.strip()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _build_candidate(
|
|
239
|
+
*,
|
|
240
|
+
original_url: str,
|
|
241
|
+
provider: str,
|
|
242
|
+
status: int,
|
|
243
|
+
body: str,
|
|
244
|
+
resolved_url: str,
|
|
245
|
+
fetched_at: str,
|
|
246
|
+
expect_title: str | None,
|
|
247
|
+
) -> "FetchResult":
|
|
248
|
+
"""Turn one raw provider response into a validated FetchResult candidate."""
|
|
249
|
+
ok = status is not None and status < 400 and bool(body.strip())
|
|
250
|
+
clean = _clean_for_provider(provider, body) if ok else ""
|
|
251
|
+
cand = FetchResult(
|
|
252
|
+
original_url=original_url,
|
|
253
|
+
resolved_url=resolved_url or original_url,
|
|
254
|
+
fetch_provider=provider,
|
|
255
|
+
fetch_status="FETCH_VALID" if ok else "FETCH_FAILED",
|
|
256
|
+
fetched_at=fetched_at,
|
|
257
|
+
raw_size=len(body.encode("utf-8")),
|
|
258
|
+
content=clean,
|
|
259
|
+
)
|
|
260
|
+
cand.clean_size = len(clean.encode("utf-8")) if clean else 0
|
|
261
|
+
cand.content_hash = _hash(clean) if clean else ""
|
|
262
|
+
cand.content_length = cand.clean_size
|
|
263
|
+
if cand.raw_size > 0 and cand.clean_size > 0:
|
|
264
|
+
cand.compression_ratio = 1 - (cand.clean_size / cand.raw_size)
|
|
265
|
+
cand.validation = validate_fetch_result(cand.to_dict(), expect_title=expect_title)
|
|
266
|
+
return cand
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _copy_candidate(target: "FetchResult", cand: "FetchResult", *, status: str) -> None:
|
|
270
|
+
"""Copy a candidate's content/provenance into the final result."""
|
|
271
|
+
target.resolved_url = cand.resolved_url
|
|
272
|
+
target.fetch_provider = cand.fetch_provider
|
|
273
|
+
target.fetch_status = status
|
|
274
|
+
target.content = cand.content
|
|
275
|
+
target.raw_size = cand.raw_size
|
|
276
|
+
target.clean_size = cand.clean_size
|
|
277
|
+
target.compression_ratio = cand.compression_ratio
|
|
278
|
+
target.content_hash = cand.content_hash
|
|
279
|
+
target.content_length = cand.content_length
|
|
280
|
+
target.validation = cand.validation
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass
|
|
284
|
+
class FetchResult:
|
|
285
|
+
"""One fetch attempt outcome (mirrors schemas/fetch-result.schema.json)."""
|
|
286
|
+
|
|
287
|
+
original_url: str
|
|
288
|
+
resolved_url: str = ""
|
|
289
|
+
fetch_method: str = "smart_web_fetch"
|
|
290
|
+
fetch_provider: str = "builtin"
|
|
291
|
+
fetch_status: str = "FETCH_FAILED"
|
|
292
|
+
fetched_at: str = ""
|
|
293
|
+
content_hash: str = ""
|
|
294
|
+
content_length: int = 0
|
|
295
|
+
raw_size: int = 0
|
|
296
|
+
clean_size: int = 0
|
|
297
|
+
compression_ratio: float = 0.0
|
|
298
|
+
fallback_used: bool = False
|
|
299
|
+
fallback_chain: list[str] = field(default_factory=list)
|
|
300
|
+
content: str = ""
|
|
301
|
+
validation: dict[str, Any] = field(default_factory=dict)
|
|
302
|
+
|
|
303
|
+
def to_dict(self) -> dict[str, Any]:
|
|
304
|
+
return {
|
|
305
|
+
"original_url": self.original_url,
|
|
306
|
+
"resolved_url": self.resolved_url,
|
|
307
|
+
"fetch_method": self.fetch_method,
|
|
308
|
+
"fetch_provider": self.fetch_provider,
|
|
309
|
+
"fetch_status": self.fetch_status,
|
|
310
|
+
"fetched_at": self.fetched_at,
|
|
311
|
+
"content_hash": self.content_hash,
|
|
312
|
+
"content_length": self.content_length,
|
|
313
|
+
"raw_size": self.raw_size,
|
|
314
|
+
"clean_size": self.clean_size,
|
|
315
|
+
"compression_ratio": round(self.compression_ratio, 3),
|
|
316
|
+
"fallback_used": self.fallback_used,
|
|
317
|
+
"fallback_chain": self.fallback_chain,
|
|
318
|
+
"content": self.content if self.fetch_status != "FETCH_FAILED" else "",
|
|
319
|
+
"validation": self.validation,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def fetch_url(
|
|
324
|
+
url: str,
|
|
325
|
+
*,
|
|
326
|
+
use_smart_fetch: bool = True,
|
|
327
|
+
timeout: int = 20,
|
|
328
|
+
expect_title: str | None = None,
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
"""Fetch a URL through the degradation chain.
|
|
331
|
+
|
|
332
|
+
fetch -> validate -> decide fallback per provider: each attempt is validated
|
|
333
|
+
immediately and only validation.passed=True ends the chain; HTTP 200 with a
|
|
334
|
+
captcha/login/error page or a too-short body continues to the next provider.
|
|
335
|
+
If no provider passes, the best readable attempt is kept as FETCH_PARTIAL
|
|
336
|
+
(rule-confirmed before extraction), otherwise FETCH_FAILED. Never raises
|
|
337
|
+
for content issues.
|
|
338
|
+
|
|
339
|
+
Private targets (original URL private, or a redirect/DNS resolution landing
|
|
340
|
+
on a private network) abort the chain: third-party providers never see
|
|
341
|
+
private content.
|
|
342
|
+
"""
|
|
343
|
+
result = FetchResult(original_url=url, fetched_at=datetime.now(timezone.utc).isoformat())
|
|
344
|
+
# Scheme whitelist: refuse non-http(s) BEFORE any read attempt (P1-2) —
|
|
345
|
+
# urllib would happily read file:// with its default handlers.
|
|
346
|
+
if urlparse(url).scheme.lower() not in ("http", "https"):
|
|
347
|
+
result.fetch_status = "FETCH_FAILED"
|
|
348
|
+
result.validation = {
|
|
349
|
+
"passed": False,
|
|
350
|
+
"checks": {"scheme_allowed": False, "http_success": False},
|
|
351
|
+
"issues": [f"unsupported URL scheme: {urlparse(url).scheme!r} (only http/https)"],
|
|
352
|
+
}
|
|
353
|
+
return result.to_dict()
|
|
354
|
+
# Private URLs are handled locally only (no third-party cleaning providers):
|
|
355
|
+
# the chain is trimmed so jina_reader/markdown_new never receive private
|
|
356
|
+
# content (P1-1, invariant stated in LOCAL_PROVIDERS).
|
|
357
|
+
chain = FETCH_PROVIDERS if use_smart_fetch else ("builtin", "raw_html")
|
|
358
|
+
original_private = is_private_url(url)
|
|
359
|
+
if original_private:
|
|
360
|
+
chain = tuple(p for p in chain if p in LOCAL_PROVIDERS)
|
|
361
|
+
best_partial: FetchResult | None = None
|
|
362
|
+
|
|
363
|
+
for provider in chain:
|
|
364
|
+
if result.fetch_status == "FETCH_VALID":
|
|
365
|
+
break
|
|
366
|
+
try:
|
|
367
|
+
status, body, resolved_url = _PROVIDER_FETCHERS[provider](url, timeout)
|
|
368
|
+
except (_BodyTooLarge, urllib.error.URLError, OSError, ValueError) as exc:
|
|
369
|
+
result.fallback_chain.append(f"{provider}:error")
|
|
370
|
+
log.debug("provider error provider=%s url=%s err=%s", provider, url, exc)
|
|
371
|
+
continue
|
|
372
|
+
result.fallback_chain.append(f"{provider}:{status}")
|
|
373
|
+
log.debug("provider attempt provider=%s status=%s url=%s", provider, status, url)
|
|
374
|
+
|
|
375
|
+
# Security gate: a public request that resolves (directly or via DNS)
|
|
376
|
+
# to a private network must abort the whole chain.
|
|
377
|
+
if provider in LOCAL_PROVIDERS and not original_private:
|
|
378
|
+
if is_private_url(resolved_url) or resolves_to_private(resolved_url) is True:
|
|
379
|
+
result.fallback_chain.append(f"{provider}:private_target")
|
|
380
|
+
result.validation = {
|
|
381
|
+
"passed": False,
|
|
382
|
+
"checks": {"private_target": True, "http_success": False},
|
|
383
|
+
"issues": ["fetch resolved to a private/local network; chain aborted"],
|
|
384
|
+
}
|
|
385
|
+
log.warning("private-target abort provider=%s url=%s resolved=%s",
|
|
386
|
+
provider, url, resolved_url)
|
|
387
|
+
return result.to_dict()
|
|
388
|
+
|
|
389
|
+
cand = _build_candidate(
|
|
390
|
+
original_url=url,
|
|
391
|
+
provider=provider,
|
|
392
|
+
status=status,
|
|
393
|
+
body=body,
|
|
394
|
+
resolved_url=resolved_url,
|
|
395
|
+
fetched_at=result.fetched_at,
|
|
396
|
+
expect_title=expect_title,
|
|
397
|
+
)
|
|
398
|
+
if cand.validation.get("passed"):
|
|
399
|
+
# The gate passed: accept this provider's content and stop.
|
|
400
|
+
_copy_candidate(result, cand, status="FETCH_VALID")
|
|
401
|
+
result.fallback_used = provider != "builtin"
|
|
402
|
+
break
|
|
403
|
+
# Validation failed (captcha / login / error / short body / URL
|
|
404
|
+
# mismatch): keep the best readable attempt as FETCH_PARTIAL and
|
|
405
|
+
# continue down the chain.
|
|
406
|
+
if cand.fetch_status == "FETCH_VALID" and (
|
|
407
|
+
best_partial is None or cand.clean_size > best_partial.clean_size
|
|
408
|
+
):
|
|
409
|
+
best_partial = cand
|
|
410
|
+
|
|
411
|
+
if result.fetch_status != "FETCH_VALID":
|
|
412
|
+
if best_partial is not None:
|
|
413
|
+
_copy_candidate(result, best_partial, status="FETCH_PARTIAL")
|
|
414
|
+
result.fallback_used = True
|
|
415
|
+
log.info("FETCH_PARTIAL url=%s chain=%s", url, result.fallback_chain)
|
|
416
|
+
else:
|
|
417
|
+
result.fetch_status = "FETCH_FAILED"
|
|
418
|
+
result.validation = {
|
|
419
|
+
"passed": False,
|
|
420
|
+
"checks": {"http_success": False, "body_length_ok": False},
|
|
421
|
+
"issues": ["all providers failed"],
|
|
422
|
+
}
|
|
423
|
+
log.warning("FETCH_FAILED url=%s chain=%s", url, result.fallback_chain)
|
|
424
|
+
|
|
425
|
+
return result.to_dict()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
if __name__ == "__main__":
|
|
429
|
+
import sys
|
|
430
|
+
|
|
431
|
+
if len(sys.argv) < 2:
|
|
432
|
+
print("usage: fetch.py <url> [expected_title]", file=sys.stderr)
|
|
433
|
+
sys.exit(2)
|
|
434
|
+
print(json.dumps(fetch_url(sys.argv[1], expect_title=sys.argv[2] if len(sys.argv) > 2 else None),
|
|
435
|
+
ensure_ascii=False, indent=2))
|