sites: raw fetch --scope fetches a page and the subpages its site handler offers as one bundle in incoming/<stem>/, with robots.txt, max_pages and pause_seconds; 10.0.0-beta.6
CI / verify (push) Successful in 8m33s
CI / pwsh (push) Successful in 2m35s
Release / release (push) Successful in 35s
Release / wiki-mirror (push) Skipped

Files changed:
- CHANGES.md
- VERSION
- docs/how-to/ingest-a-source.md
- docs/how-to/write-a-site-handler.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- raw/CONTRACT.md
- sites/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/raw/fetch.py
- tools/chemenu/config_shapes.py
- tools/chemenu/sites/__init__.py
- tools/chemenu/sites/data.py
- tools/chemenu/sites/handlers/learn-microsoft-powershell-module.json
- tools/chemenu/sites/site.schema.json
- tools/chemenu/tests/test_raw_fetch.py
- tools/chemenu/tests/test_raw_fetch_scope.py
- tools/chemenu/tests/test_site_files.py
- tools/chemenu/tests/test_sites.py
- tools/chemenu/web_capture.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-11 18:36:10 +02:00
1 parent f25ee6a1ce
commit 21cdd787b7
21 files changed
+835 -41

No files matched your search

+7 -1
View File
@@ -1425,13 +1425,14 @@ Capture a web page the user names into `incoming/`: the HTML as received plus a
**SYNOPSIS**
- `wikitool raw fetch <url> [--name <stem>]` - Fetch the page and write `incoming/<stem>.html` and `incoming/<stem>.md`
- `wikitool raw fetch <url> --scope [--name <stem>]` - Fetch the page and the subpages its site handler offers, and write the folder `incoming/<stem>/` - a `.html` + `.md` pair per page and the manifest `_scope.json`
- `wikitool raw fetch --html incoming/<file>.html --url <url>` - Derive the `.md` from a page a human saved from their browser - no network access
**PROPERTIES**
- effect: write
- idempotent: no
- atomic: Yes for what it leaves behind - one or two new files in `incoming/`, created exclusively; a failure on the second removes the first
- atomic: Yes for what it leaves behind - one or two new files in `incoming/`, created exclusively; a failure on the second removes the first. With `--scope`: all of the folder or none of it - the pages are fetched into a temporary place and moved in as a whole
- budget: counted
- network: yes
@@ -1439,6 +1440,7 @@ Capture a web page the user names into `incoming/`: the HTML as received plus a
- `tools/wikitool raw fetch https://example.org/blog/post`
- `tools/wikitool raw fetch https://example.org/ --name example-start`
- `tools/wikitool raw fetch https://learn.microsoft.com/en-us/powershell/module/oslicense/ --scope`
- `tools/wikitool raw fetch --html incoming/post.html --url https://example.org/blog/post`
**EXIT STATUS**
@@ -1448,6 +1450,7 @@ Capture a web page the user names into `incoming/`: the HTML as received plus a
- 1 The `sites` section of the settings is malformed, `disabled` names a handler that does not exist, or a file in `sites/` cannot be used and names this URL's host (or none can be read)
- 1 A target file already exists in `incoming/`
- 1 The server answered with an HTTP error, could not be reached, took longer than 30 s, or sent more than 25 MiB
- 1 raw fetch --scope: No site handler offers subpages for the URL, the page offers none or more than `sites.max_pages`, `robots.txt` forbids a subpage or cannot be read, a subpage cannot be fetched, answers something other than HTML or leaves the host, the folder `incoming/<stem>/` exists, or a path would be over the budget
- 1 raw fetch --html: `--url` is missing, the file is not directly in `incoming/` or does not exist, or `incoming/<stem>.md` already exists
**ON FAILURE**
@@ -1456,6 +1459,7 @@ Capture a web page the user names into `incoming/`: the HTML as received plus a
- The `sites` section of the settings is malformed, `disabled` names a handler that does not exist, or a file in `sites/` cannot be used and names this URL's host (or none can be read) -> Nothing was fetched or written. Fix the section or the handler file named in the message - `tools/wikitool config set`, the settings file or the file in `sites/` (`wikitool doctor` lists the files) - then retry once
- A target file already exists in `incoming/` -> Nothing was written or overwritten. Accept or remove what is there, or pass `--name <stem>`, then retry once
- The server answered with an HTTP error, could not be reached, took longer than 30 s, or sent more than 25 MiB -> Nothing was written. An HTTP 4xx is not fixed by retrying - check the URL with the user; for a paywall or login, save the page in a browser and use `--html`. An unreachable host or a timeout may be retried once
- raw fetch --scope: No site handler offers subpages for the URL, the page offers none or more than `sites.max_pages`, `robots.txt` forbids a subpage or cannot be read, a subpage cannot be fetched, answers something other than HTML or leaves the host, the folder `incoming/<stem>/` exists, or a path would be over the budget -> Nothing was written. Read the message: fetch the page without `--scope`, raise the limit with `tools/wikitool config set`, or pass a shorter `--name`, then retry once. A subpage `robots.txt` forbids is not fetched around it - tell the user
- raw fetch --html: `--url` is missing, the file is not directly in `incoming/` or does not exist, or `incoming/<stem>.md` already exists -> Fix the named argument and retry once - nothing was written. A saved page inside a subdirectory of `incoming/` is moved up into `incoming/` first
**NEVER**
@@ -1470,6 +1474,8 @@ Capture a web page the user names into `incoming/`: the HTML as received plus a
- Character set, in this order: byte-order mark, the HTTP `Content-Type` charset, a `<meta>` declaration in the first 4 KiB, UTF-8. Undecodable bytes are replaced with U+FFFD and reported; the header names the charset and where it came from.
- Derived text: the content root is `<main>`, else the single `<article>`, else `<body>`; `script`, `style`, `noscript`, `nav`, `header`, `footer`, `aside`, `form`, `template` and `svg` are dropped below it. Headings, lists, code, tables and links (made absolute) become Markdown. The same bytes always give the same text.
- Site handlers: for the pages of a website the stack or the instance knows (the stack's today: `learn.microsoft.com`; the instance's are JSON files in `sites/`), `raw fetch <url>` fetches the standard form of the URL (the locale segment becomes `en-us`, the `view=` query stays) and `<url>` as typed goes into the header as `requested_url`; `url` is the form fetched. The handler also removes the page furniture the website renders around the article from the derived text - never from the `.html` - and prints hints that apply to this page as `CHECK` lines: act on one before accepting. `--html --url <url>` removes the same furniture but keeps the URL as typed. A URL no handler claims is handled as before. The header names an instance's handler as `<name> (instance)`. A handler file of the instance that cannot be used, and names the URL's host or no readable host, stops the fetch before anything is fetched or written. The `sites` section of the settings switches handlers off, sets the locale and adds hint lines of the instance's own per host.
- `--scope` is for a page whose site handler offers subpages - the stack's today: a PowerShell module's overview page on `learn.microsoft.com`. The handler names them from the page's own HTML: the links it selects that lead to the same host, under the path of the page itself (or the prefix the handler sets); no other link is followed, and a subpage's own links never are. Without `--scope` the one page is fetched as before and a `CHECK` line says how many subpages it offers. With it, `incoming/<stem>/` holds the page and every subpage as a `<name>.html` + `<name>.md` pair - the `.html` the response byte for byte, the `.md` derived as for a single page, `<name>` from the subpage's URL - and `_scope.json`: the URL, the retrieval time and the `sha256` of the `.html` of each page. `raw accept incoming/<stem>` takes the folder as one bundle.
- `--scope` is bounded. The `robots.txt` of the host is honoured for the subpages (not for the page itself): one it forbids refuses the whole fetch and names the URL. More subpages than `sites.max_pages` (200) refuses it before the first subpage is requested; `sites.pause_seconds` (1) is the pause before each subpage request. Both are settings of the `instance` tier. Everything is checked before the first write: the path budget under `raw/<YYYY>/<MM>/`, a folder of that name already in `incoming/` or `raw/`. A failing request leaves `incoming/` as it was.
- The stem is the URL's last path segment without its extension, else its host name - ASCII, lowercase, `-`-separated, at most 60 characters; `--name` overrides it.
- A `text/plain` or `text/markdown` response is stored as received as `.txt`/`.md`; any other non-HTML response (PDF, image, ...) as received under the extension of its content type. Neither gets a derived file or a header.
- Only `http`/`https`, on every redirect hop too. 30 s for the whole transfer, 25 MiB at most. No cookies, no JavaScript: a derived text under 200 characters is written, with a warning to read it before accepting.
+1 -1
View File
@@ -118,7 +118,7 @@ tools/
kb_state.py the KB version (.wikitool-kb.json) and the migration chain
corpus_diff.py invariant comparison of kb/ between two revisions
web_capture.py `raw fetch`'s core: fetch a page, decide its charset, derive Markdown-like text from the HTML - standard library only, deterministic
sites/ the site handlers behind `raw fetch`: a registry (`__init__.py`), the JSON file format (`site.schema.json`, `data.py`) and the stack's handlers as one file per website under `handlers/`, each stating the standard form of its URLs, the page furniture to leave out of the derived text, and hint lines - all of it data (rewrites, selectors, text); the instance's own sit in `sites/` at the root
sites/ the site handlers behind `raw fetch`: a registry (`__init__.py`), the JSON file format (`site.schema.json`, `data.py`) and the stack's handlers as one file per website under `handlers/`, each stating the standard form of its URLs, the page furniture to leave out of the derived text, hint lines, and which links are subpages for `raw fetch --scope` - all of it data (rewrites, selectors, text); the instance's own sit in `sites/` at the root
filters/ the import filters behind `raw derive`: a registry (`__init__.py`) and one module per format family (HTML, mail, Outlook MSG, Office, PDF, ZIP), each registering the extensions it reads - standard library, or a package imported lazily so a missing one fails only its own format
repo_capture.py `raw capture`/`raw status`'s core: resolve a ref rule, fetch it into a bare cache, select files by glob as blobs, the `_capture.json` manifest - git with the host's credentials, never a prompt; plus the one write back, a single-file commit pushed without force for `export guidelines`
guideline_export.py `export guidelines`' core: select pages by `search` predicates, render them into one deterministic GUIDELINES.md, and recognise an export again (`EXPORT_MARKER`) - with no CLI attached
+247 -9
View File
@@ -7,23 +7,38 @@ the caller is told.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import tempfile
import time
import urllib.parse
from pathlib import Path
from typing import Optional
from typing import Annotated, Optional
import typer
from rich.markup import escape
from chemenu import cli_contract, sites, web_capture
from chemenu.commands._util import fail, rel_path, success
from chemenu import cli_contract, config, sites, web_capture
from chemenu.commands._util import fail, path_budget_problem_for, rel_path, success
from chemenu.errors import ChemenuError
from chemenu.version import VersionError, read_version
from chemenu.commands.raw._common import (
_folder_collision_message,
_incoming_dir,
_occupied_stems,
_resolve,
_shard_dir,
_unusable_name,
_validate_under_incoming,
_write_exclusive,
)
SCOPE_MANIFEST = "_scope.json"
SCOPE_MANIFEST_VERSION = 1
_LISTED_REFUSALS = 10
_TEASER_HINT = (
" If the text stops at a teaser (paywall, login, script-rendered page): save the page from a\n"
@@ -73,15 +88,22 @@ def _warn(derivation: web_capture.Derivation, md_path: Path) -> None:
)
def _plan(url: str, *, canonical: bool) -> sites.Plan:
def _preferences() -> sites.Preferences:
try:
return sites.plan_for(url, sites.read_preferences(), canonical=canonical)
return sites.read_preferences()
except ChemenuError as exc:
fail(escape(str(exc)))
def _echo_checks(plan: sites.Plan, derivation: web_capture.Derivation) -> None:
for line in plan.checks(derivation.decoded.text):
def _plan(url: str, *, canonical: bool, preferences: Optional[sites.Preferences] = None) -> sites.Plan:
try:
return sites.plan_for(url, preferences or _preferences(), canonical=canonical)
except ChemenuError as exc:
fail(escape(str(exc)))
def _echo_checks(plan: sites.Plan, derivation: web_capture.Derivation, subpages: int = 0) -> None:
for line in plan.checks(derivation.decoded.text, subpages):
typer.echo(f"CHECK {line}")
@@ -121,6 +143,176 @@ def _fetch_html_file(html: Path, source_url: Optional[str]) -> None:
))
def _subpage_names(stem: str, urls: list[str]) -> list[str]:
"""One file stem per subpage, from its URL, unique within the bundle and
never the overview's."""
taken = {stem}
names = []
for url in urls:
base = web_capture.stem_for(url)
name, number = base, 1
while name in taken:
number += 1
name = f"{base}-{number}"
taken.add(name)
names.append(name)
return names
def _listed(urls: list[str]) -> str:
shown = "\n".join(f" - {url}" for url in urls[:_LISTED_REFUSALS])
more = len(urls) - _LISTED_REFUSALS
return shown + (f"\n ... and {more} more" if more > 0 else "")
def _page_entry(name: str, role: str, response: web_capture.Response, retrieved: str) -> dict:
return {
"name": name,
"role": role,
"url": response.url,
"final_url": response.final_url,
"retrieved": retrieved,
"sha256": hashlib.sha256(response.body).hexdigest(),
"files": [f"{name}.html", f"{name}.md"],
}
def _fetch_scope(url: str, name: Optional[str]) -> None:
preferences = _preferences()
plan = _plan(url, canonical=True, preferences=preferences)
stem = name or web_capture.stem_for(plan.url)
if _unusable_name(stem):
fail(escape(f"{stem!r} is not a usable folder name - choose another with --name."))
target = _incoming_dir() / stem
def refuse_taken() -> None:
if os.path.lexists(target):
fail(escape(
f"{rel_path(target)} already exists in incoming/ - raw fetch never overwrites. "
"Accept or remove what is there, or pass --name <stem>."
))
holder = _occupied_stems(config.RAW_DIR).get(stem)
if holder is not None:
fail(escape(_folder_collision_message(stem, holder)))
if plan.site is None or plan.site.subpages is None:
fail(escape(
f"--scope needs a site handler that offers subpages, and none applies to {plan.url}. "
"Fetch the page without --scope."
))
refuse_taken()
agent = _user_agent()
try:
index = web_capture.fetch(
plan.url, agent, timeout=web_capture.TIMEOUT_SECONDS, max_bytes=web_capture.MAX_BYTES
)
except ChemenuError as exc:
fail(escape(str(exc)))
index_retrieved = web_capture.utc_now()
if index.media_type not in web_capture.HTML_TYPES:
fail(escape(
f"{plan.url} answered {index.media_type or 'no content type'}, not an HTML page - "
"--scope bundles HTML pages. Nothing was written."
))
index_derivation = web_capture.derive_document(
index.body, index.final_url, f"{stem}.html", response=index,
drop=plan.drop, site=plan.label, requested_url=plan.requested_url, derived_at=index_retrieved,
)
urls = plan.subpages(index_derivation.decoded.text, index.final_url)
if not urls:
fail(escape(f"{index.final_url} offers no subpages - nothing was written. Fetch it without --scope."))
if len(urls) > preferences.max_pages:
fail(escape(
f"{index.final_url} offers {len(urls)} subpages, more than the {preferences.max_pages} "
"that `sites.max_pages` allows - nothing was fetched or written. Raise the setting "
"(`tools/wikitool config set sites.max_pages <n>`) if the whole family is wanted."
))
try:
forbidden = web_capture.robots_refusals(urls, agent)
except ChemenuError as exc:
fail(escape(f"{exc} Nothing was written."))
if forbidden:
fail(escape(
f"robots.txt of {urllib.parse.urlsplit(index.final_url).netloc} forbids {len(forbidden)} "
f"of the {len(urls)} subpages - none was fetched, nothing was written:\n"
f"{_listed(forbidden)}"
))
names = _subpage_names(stem, urls)
files = [f"{stem}.html", f"{stem}.md", SCOPE_MANIFEST]
files += [f"{n}{ext}" for n in names for ext in (".html", ".md")]
over = [p for rel in files if (p := path_budget_problem_for(_shard_dir() / stem / rel))]
if over:
fail(escape(
f"Cannot fetch - these files would be over the path budget at their place under raw/:\n"
f"{_listed(over)}\n Choose a shorter --name. Nothing was written."
))
work = Path(tempfile.mkdtemp(prefix="wikitool-scope-"))
try:
bundle = work / stem
bundle.mkdir()
host = urllib.parse.urlsplit(plan.url).netloc.lower()
(bundle / f"{stem}.html").write_bytes(index.body)
(bundle / f"{stem}.md").write_bytes(index_derivation.markdown)
entries = [_page_entry(stem, "index", index, index_retrieved)]
derivations = [(stem, index_derivation)]
for position, (sub_url, sub_name) in enumerate(zip(urls, names), start=1):
if preferences.pause_seconds:
time.sleep(preferences.pause_seconds)
try:
page = web_capture.fetch(
sub_url, agent, timeout=web_capture.TIMEOUT_SECONDS, max_bytes=web_capture.MAX_BYTES
)
except ChemenuError as exc:
fail(escape(f"{exc} (subpage {position} of {len(urls)}) Nothing was written."))
retrieved = web_capture.utc_now()
if urllib.parse.urlsplit(page.final_url).netloc.lower() != host:
fail(escape(
f"{sub_url} was redirected to {page.final_url}, another host - --scope stays on "
f"{host}. Nothing was written."
))
if page.media_type not in web_capture.HTML_TYPES:
fail(escape(
f"{sub_url} answered {page.media_type or 'no content type'}, not an HTML page - "
"--scope bundles HTML pages. Nothing was written."
))
sub_plan = _plan(sub_url, canonical=False, preferences=preferences)
derivation = web_capture.derive_document(
page.body, page.final_url, f"{sub_name}.html", response=page,
drop=sub_plan.drop, site=sub_plan.label, derived_at=retrieved,
)
(bundle / f"{sub_name}.html").write_bytes(page.body)
(bundle / f"{sub_name}.md").write_bytes(derivation.markdown)
entries.append(_page_entry(sub_name, "subpage", page, retrieved))
derivations.append((sub_name, derivation))
manifest = {
"scope": SCOPE_MANIFEST_VERSION,
"fetched_by": "wikitool raw fetch --scope",
"url": plan.url,
**({"requested_url": plan.requested_url} if plan.requested_url else {}),
"retrieved": index_retrieved,
"pages": entries,
}
(bundle / SCOPE_MANIFEST).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n")
refuse_taken()
shutil.move(str(bundle), str(target))
except OSError as exc:
fail(escape(f"Could not write {rel_path(target)}: {exc}. Nothing was left in incoming/."))
finally:
shutil.rmtree(work, ignore_errors=True)
for page_name, derivation in derivations:
_warn(derivation, target / f"{page_name}.md")
_echo_checks(plan, index_derivation)
success(escape(
f"Fetched {_fetched(plan)} with {len(urls)} subpage(s) to {rel_path(target)}/ "
f"({len(entries)} page pairs and {SCOPE_MANIFEST}).\n"
+ _next_lines([target], plan.url)
))
@cli_contract.record(cli_contract.CommandRecord(
path="raw fetch",
summary="Capture a web page the user names into `incoming/`: the HTML as received plus a "
@@ -131,6 +323,11 @@ def _fetch_html_file(html: Path, source_url: Optional[str]) -> None:
usage="raw fetch <url> [--name <stem>]",
notes="Fetch the page and write `incoming/<stem>.html` and `incoming/<stem>.md`",
),
cli_contract.Variant(
usage="raw fetch <url> --scope [--name <stem>]",
notes="Fetch the page and the subpages its site handler offers, and write the folder "
"`incoming/<stem>/` - a `.html` + `.md` pair per page and the manifest `_scope.json`",
),
cli_contract.Variant(
usage="raw fetch --html incoming/<file>.html --url <url>",
notes="Derive the `.md` from a page a human saved from their browser - no network "
@@ -141,7 +338,8 @@ def _fetch_html_file(html: Path, source_url: Optional[str]) -> None:
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes for what it leaves behind - one or two new files in `incoming/`, created "
"exclusively; a failure on the second removes the first",
"exclusively; a failure on the second removes the first. With `--scope`: all of the folder "
"or none of it - the pages are fetched into a temporary place and moved in as a whole",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
@@ -173,6 +371,23 @@ def _fetch_html_file(html: Path, source_url: Optional[str]) -> None:
"URL's host or no readable host, stops the fetch before anything is fetched or written. "
"The `sites` section of the settings switches handlers "
"off, sets the locale and adds hint lines of the instance's own per host.",
"`--scope` is for a page whose site handler offers subpages - the stack's today: a "
"PowerShell module's overview page on `learn.microsoft.com`. The handler names them from "
"the page's own HTML: the links it selects that lead to the same host, under the path of "
"the page itself (or the prefix the handler sets); no other link is followed, and a "
"subpage's own links never are. Without `--scope` the one page is fetched as before and "
"a `CHECK` line says how many subpages it offers. With it, `incoming/<stem>/` holds the "
"page and every subpage as a `<name>.html` + `<name>.md` pair - the `.html` the response "
"byte for byte, the `.md` derived as for a single page, `<name>` from the subpage's URL - "
"and `_scope.json`: the URL, the retrieval time and the `sha256` of the `.html` of each "
"page. `raw accept incoming/<stem>` takes the folder as one bundle.",
"`--scope` is bounded. The `robots.txt` of the host is honoured for the subpages "
"(not for the page itself): one it forbids refuses the whole fetch and names the URL. "
"More subpages than `sites.max_pages` (200) refuses it before the first subpage is "
"requested; `sites.pause_seconds` (1) is the pause before each subpage request. Both "
"are settings of the `instance` tier. Everything is checked before the first write: "
"the path budget under `raw/<YYYY>/<MM>/`, a folder of that name already in `incoming/` "
"or `raw/`. A failing request leaves `incoming/` as it was.",
"The stem is the URL's last path segment without its extension, else its host name - "
"ASCII, lowercase, `-`-separated, at most 60 characters; `--name` overrides it.",
"A `text/plain` or `text/markdown` response is stored as received as `.txt`/`.md`; any "
@@ -215,6 +430,16 @@ def _fetch_html_file(html: Path, source_url: Optional[str]) -> None:
"with the user; for a paywall or login, save the page in a browser and use "
"`--html`. An unreachable host or a timeout may be retried once",
),
cli_contract.Failure(
label="raw fetch --scope",
cause="No site handler offers subpages for the URL, the page offers none or more than "
"`sites.max_pages`, `robots.txt` forbids a subpage or cannot be read, a subpage cannot "
"be fetched, answers something other than HTML or leaves the host, the folder "
"`incoming/<stem>/` exists, or a path would be over the budget",
reaction="Nothing was written. Read the message: fetch the page without `--scope`, "
"raise the limit with `tools/wikitool config set`, or pass a shorter `--name`, then "
"retry once. A subpage `robots.txt` forbids is not fetched around it - tell the user",
),
cli_contract.Failure(
label="raw fetch --html",
cause="`--url` is missing, the file is not directly in `incoming/` or does not "
@@ -226,6 +451,7 @@ def _fetch_html_file(html: Path, source_url: Optional[str]) -> None:
examples=(
"tools/wikitool raw fetch https://example.org/blog/post",
"tools/wikitool raw fetch https://example.org/ --name example-start",
"tools/wikitool raw fetch https://learn.microsoft.com/en-us/powershell/module/oslicense/ --scope",
"tools/wikitool raw fetch --html incoming/post.html --url https://example.org/blog/post",
),
never=(
@@ -255,6 +481,13 @@ def raw_fetch_command(
name: Optional[str] = typer.Option(
None, "--name", help="File stem in incoming/ instead of the one computed from the URL"
),
# `Annotated`, so that a direct call - the way the test suite calls this
# function - gets a real `False` here rather than a truthy `OptionInfo`.
scope: Annotated[bool, typer.Option(
"--scope",
help="Also fetch the subpages the site handler offers for this page, into the folder "
"incoming/<stem>/",
)] = False,
):
"""Capture a web page into incoming/ as received HTML plus derived text.
See raw/CONTRACT.md "Getting a URL in: `raw fetch`"."""
@@ -263,6 +496,8 @@ def raw_fetch_command(
if html is not None:
if name is not None:
fail("--name does not apply to --html - the stem is the saved file's own.")
if scope:
fail("--scope does not apply to --html - it fetches subpages, and --html makes no request.")
_fetch_html_file(html, source_url)
return
if source_url is not None:
@@ -270,6 +505,9 @@ def raw_fetch_command(
if name is not None and (not name.strip() or name in (".", "..") or any(c in name for c in "/\\")):
fail(f"--name {name!r} is not a usable file stem: no path separators, not empty.")
if scope:
_fetch_scope(url, name)
return
plan = _plan(url, canonical=True)
stem = name or web_capture.stem_for(plan.url)
@@ -291,7 +529,7 @@ def raw_fetch_command(
)
_write_exclusive([(html_path, response.body), (md_path, derivation.markdown)])
_warn(derivation, md_path)
_echo_checks(plan, derivation)
_echo_checks(plan, derivation, len(plan.subpages(derivation.decoded.text, response.final_url)))
success(escape(
f"Fetched {_fetched(plan)} to {rel_path(html_path)}, {rel_path(md_path)}\n"
+ _next_lines([html_path, md_path], plan.url) + "\n"
+20 -2
View File
@@ -248,6 +248,10 @@ def _positive(value: int) -> Optional[str]:
return None if value > 0 else "must be positive"
def non_negative(value: float) -> Optional[str]:
return None if value >= 0 else "must not be negative"
def dotted_extensions(value: tuple[str, ...]) -> Optional[str]:
if not value:
return "must not be empty"
@@ -453,8 +457,8 @@ SITES = Shape(
key="sites",
title="Site handlers",
purpose="Tunes the site handlers `raw fetch` applies to the pages of well-known websites: "
"switches one off, picks the locale a handler fetches, and adds hint lines of the "
"instance's own per host.",
"switches one off, picks the locale a handler fetches, adds hint lines of the "
"instance's own per host, and bounds `--scope`, the fetch of a page with its subpages.",
fields=(
Field(
"disabled", "array of strings", "The names of the site handlers that do not apply. "
@@ -479,10 +483,23 @@ SITES = Shape(
default=[], json_type="array", coerce=site_notes, check=site_notes_problem,
tier=INSTANCE,
),
positive_integer(
"max_pages", "The most subpages `raw fetch --scope` fetches for one page. A page that "
"offers more is refused before the first subpage is requested.",
default=200, tier=INSTANCE,
),
Field(
"pause_seconds", "number", "The pause, in seconds, before each subpage request of "
"`raw fetch --scope`. `0` means none.",
default=1, json_type="number", coerce=float, check=non_negative,
lenient="A number written as a string is accepted.", tier=INSTANCE,
),
),
examples=({
"disabled": ["learn-microsoft-powershell-module"],
"locale": "en-us",
"max_pages": 200,
"pause_seconds": 1,
"notes": [{
"host": "learn.microsoft.com", "path": "^/[a-z-]+/azure/",
"lines": ["Check the Azure retirement notice at the top of the page."],
@@ -515,6 +532,7 @@ _JSON_TYPES: dict[str, Callable[[Any], bool]] = {
"string": lambda v: isinstance(v, str),
"array": lambda v: isinstance(v, list),
"object": lambda v: isinstance(v, dict),
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
}
_TYPE_NAMES = {bool: "a boolean", int: "an integer", float: "a number", str: "a string",
+66 -4
View File
@@ -14,7 +14,9 @@ with an optional condition. So a handler is a JSON file (`data.py` reads it,
in `sites/` at its root, by the same building blocks. An instance handler for a
host and path the stack has a handler for replaces it; the `sites` section of the
settings still switches handlers off, sets the `locale` and adds hint lines
(`notes`). A handler may still be registered from a Python module in this
(`notes`). A handler may also offer the subpages of a page
(`Subpages`: the links to take, under a path prefix), which `raw fetch --scope`
fetches as one bundle. A handler may still be registered from a Python module in this
package. The core knows neither `typer` nor `rich` and raises `ValidationError`.
"""
from __future__ import annotations
@@ -33,6 +35,7 @@ from chemenu.web_capture import Rule
MAX_CHECKS = 3
LOCALE_PLACEHOLDER = "{locale}"
PAGE_PLACEHOLDER = "{page}"
INSTANCE_DIRECTORY = "sites"
@@ -79,6 +82,47 @@ class Check:
return text
@dataclass(frozen=True)
class Subpages:
"""The subpages a page offers: the links `links` selects, resolved against
the page's URL, that stay on its host and lie under `prefix`. `{page}` in
`prefix` is the path of the page itself with a trailing slash, which is
the default; the page is never its own subpage. Document order, each URL
once, the fragment dropped - and nothing else is followed."""
links: Rule
prefix: str = PAGE_PLACEHOLDER
def __post_init__(self) -> None:
if not (self.prefix.startswith(PAGE_PLACEHOLDER) or self.prefix.startswith("/")):
raise ValueError(f"a subpages prefix starts with {PAGE_PLACEHOLDER} or with /, not {self.prefix!r}")
def urls(self, html: str, page_url: str) -> list[str]:
page = urllib.parse.urlsplit(page_url)
own = urllib.parse.unquote(page.path) or "/"
own = own if own.endswith("/") else own + "/"
prefix = self.prefix.replace(PAGE_PLACEHOLDER, own)
found: list[str] = []
for element in web_capture.matching(web_capture.parse(html), self.links):
href = element.attrs.get("href", "").strip()
if not href:
continue
url = urllib.parse.urldefrag(urllib.parse.urljoin(page_url, href))[0]
parts = urllib.parse.urlsplit(url)
path = urllib.parse.unquote(parts.path)
if (
parts.scheme.lower() not in web_capture.ALLOWED_SCHEMES
or parts.netloc.lower() != page.netloc.lower()
or not path.startswith(prefix)
or path.rstrip("/") == own.rstrip("/")
or path == prefix
or url in found
):
continue
found.append(url)
return found
@dataclass(frozen=True)
class Site:
"""One handler. `host` is matched exactly (lower case); `path` is a regular
@@ -94,6 +138,7 @@ class Site:
rewrites: tuple[Rewrite, ...] = ()
drop: tuple[Rule, ...] = ()
checks: tuple[Check, ...] = ()
subpages: Optional[Subpages] = None
origin: str = "stack"
def __post_init__(self) -> None:
@@ -131,6 +176,7 @@ def inherit(candidate: Site, parent: Site) -> Site:
rewrites=parent.rewrites + candidate.rewrites,
drop=parent.drop + candidate.drop,
checks=parent.checks + candidate.checks,
subpages=candidate.subpages or parent.subpages,
)
if len(flat.checks) > MAX_CHECKS:
raise ValueError(
@@ -220,6 +266,8 @@ class Preferences:
notes: tuple[Note, ...] = ()
instance: tuple[Site, ...] = ()
broken: tuple[Broken, ...] = ()
max_pages: int = 200
pause_seconds: float = 1.0
def read_instance(root: "Path | str | None" = None) -> tuple[tuple[Site, ...], tuple[Broken, ...]]:
@@ -250,7 +298,9 @@ def read_preferences(root: "Path | str | None" = None) -> Preferences:
notes = tuple(
Note(entry["host"], entry.get("path", ""), tuple(entry["lines"])) for entry in section["notes"]
)
return Preferences(disabled, section["locale"], notes, instance, broken)
return Preferences(
disabled, section["locale"], notes, instance, broken, section["max_pages"], section["pause_seconds"]
)
@dataclass(frozen=True)
@@ -278,11 +328,23 @@ class Plan:
def drop(self) -> tuple[Rule, ...]:
return self.site.drop if self.site else ()
def checks(self, html: str) -> list[str]:
"""The `CHECK` lines for the page: the handler's, then the instance's notes."""
def checks(self, html: str, subpages: int = 0) -> list[str]:
"""The `CHECK` lines for the page: the handler's, the offer of `subpages`
subpages when there are any, then the instance's notes."""
lines = self.site.check_lines(html) if self.site else []
if subpages:
lines.append(
f"this page is an index with {subpages} subpage(s) - rerun with `--scope` to "
"fetch them as one bundle"
)
return lines + list(self.notes)
def subpages(self, html: str, page_url: str) -> list[str]:
"""The subpage URLs the handler offers for this page, else none."""
if self.site is None or self.site.subpages is None:
return []
return self.site.subpages.urls(html, page_url)
def plan_for(url: str, preferences: Preferences, *, canonical: bool = True) -> Plan:
"""The plan for `url`. `canonical=False` keeps the URL as given - for a page
+9 -1
View File
@@ -15,7 +15,7 @@ from typing import Any, Optional
from jsonschema import Draft202012Validator
from chemenu.sites import Broken, Check, Rewrite, Rule, Site, inherit
from chemenu.sites import Broken, Check, Rewrite, Rule, Site, Subpages, inherit
SCHEMA_PATH = Path(__file__).with_name("site.schema.json")
STACK_DIRECTORY = Path(__file__).with_name("handlers")
@@ -70,6 +70,13 @@ def build(name: str, document: Any, origin: str) -> Site:
if "{count}" in text and "count" not in entry:
raise ValueError("checks: a text with {count} needs a `count` rule")
checks.append(Check(text, entry.get("meta", ""), _rule(entry["count"]) if "count" in entry else None))
subpages = None
if "subpages" in document:
entry = document["subpages"]
try:
subpages = Subpages(_rule(entry["links"]), entry.get("prefix", "{page}"))
except ValueError as exc:
raise ValueError(f"subpages: {exc}") from None
try:
return Site(
name=name,
@@ -80,6 +87,7 @@ def build(name: str, document: Any, origin: str) -> Site:
rewrites=tuple(rewrites),
drop=tuple(_rule(entry) for entry in document.get("drop", [])),
checks=tuple(checks),
subpages=subpages,
origin=origin,
)
except (ValueError, re.error) as exc:
@@ -1,12 +1,9 @@
{
"host": "learn.microsoft.com",
"summary": "A PowerShell module's overview page: names the cmdlet pages it lists, which are not fetched with it.",
"summary": "A PowerShell module's overview page: offers the cmdlet pages it lists as its subpages, which `--scope` fetches with it as one bundle.",
"path": "/[a-z]{2,3}(?:-[a-z0-9]{2,8})+/powershell/module/[^/]+/?$",
"parent": "learn-microsoft",
"checks": [
{
"text": "This module page lists {count} cmdlet page(s) as links; they are not fetched - ingest the ones the topic needs, each with its own raw fetch.",
"count": {"tag": "a", "attr": "data-linktype", "value": "relative-path", "inside": "td"}
}
]
"subpages": {
"links": {"tag": "a", "attr": "data-linktype", "value": "relative-path", "inside": "td"}
}
}
+9
View File
@@ -37,6 +37,15 @@
"count": {"$ref": "#/$defs/rule"}
}
}
},
"subpages": {
"type": "object",
"additionalProperties": false,
"required": ["links"],
"properties": {
"links": {"$ref": "#/$defs/rule"},
"prefix": {"type": "string", "pattern": "^(\\{page\\}|/)", "maxLength": 200}
}
}
},
"$defs": {
+2 -2
View File
@@ -100,8 +100,8 @@ def tree(kb_dir):
return root
def _fetch(url=None, html=None, source_url=None, name=None):
return raw_fetch_command(url=url, html=html, source_url=source_url, name=name)
def _fetch(url=None, html=None, source_url=None, name=None, scope=False):
return raw_fetch_command(url=url, html=html, source_url=source_url, name=name, scope=scope)
def _refused(**kwargs) -> None:
+346
View File
@@ -0,0 +1,346 @@
"""`wikitool raw fetch --scope` - a page and the subpages its site handler offers,
fetched as one bundle.
Like `test_raw_fetch.py`, every request goes to a real `http.server` on 127.0.0.1.
The handler is an instance handler in `sites/`, because that is the form in which
the subpages building block is data an instance can write.
"""
from __future__ import annotations
import datetime
import hashlib
import json
from pathlib import Path
import pytest
import typer
from chemenu import sites, web_capture
from chemenu.commands.raw.accept import raw_accept_command
from chemenu.commands.raw.fetch import raw_fetch_command
from chemenu.config_shapes import SITES, check
from chemenu.tests.conftest import write_settings
from chemenu.tests.test_raw_fetch import Site
HANDLER = {
"host": "127.0.0.1",
"summary": "A test module: the links in its table are its subpages.",
"path": "/mod/",
"subpages": {"links": {"tag": "a", "inside": "td"}},
}
def _index(port: int) -> bytes:
return (
"<html><head><title>Module</title></head><body><main><h1>Module</h1><table>"
'<tr><td><a href="a?v=1#top">A</a></td></tr>'
'<tr><td><a href="b?v=1">B</a></td></tr>'
'<tr><td><a href="/mod/c">C</a></td></tr>'
'<tr><td><a href="a?v=1">A again</a></td></tr>'
'<tr><td><a href="/elsewhere/x">outside the prefix</a></td></tr>'
f'<tr><td><a href="http://localhost:{port}/mod/foreign">another host</a></td></tr>'
'<tr><td><a href="mailto:x@example.org">mail</a></td></tr>'
'<tr><td><a href="./">the page itself</a></td></tr>'
"</table>"
'<p><a href="/mod/hidden">a link outside the table</a></p>'
"</main></body></html>"
).encode()
def _sub(name: str) -> bytes:
return (
f"<html><head><title>{name}</title></head><body><main><h1>{name}</h1>"
f"<p>{name} text</p></main></body></html>"
).encode()
@pytest.fixture
def site(monkeypatch):
for var in ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
monkeypatch.delenv(var, raising=False)
server = Site()
yield server
server.stop()
@pytest.fixture
def tree(kb_dir, monkeypatch, site):
root = kb_dir.parent
(root / "raw").mkdir()
(root / "incoming").mkdir(exist_ok=True)
(root / "sites").mkdir()
(root / "sites/test-module.json").write_text(json.dumps(HANDLER), encoding="utf-8")
write_settings(root, monkeypatch, sites={"pause_seconds": 0})
return root
@pytest.fixture
def module(site):
"""The index and the pages around it, served."""
site.html("/mod/", _index(site.httpd.server_address[1]))
for name in ("a?v=1", "b?v=1", "c", "hidden", "foreign"):
site.html(f"/mod/{name}", _sub(name.split("?")[0]))
return site
def _fetch(url, name=None, scope=True):
return raw_fetch_command(url=url, html=None, source_url=None, name=name, scope=scope)
def _refused(url, **kwargs) -> None:
with pytest.raises(typer.Exit) as excinfo:
_fetch(url, **kwargs)
assert excinfo.value.exit_code == 1
def _snapshot(root: Path) -> list[str]:
return sorted(str(p.relative_to(root)) for p in root.rglob("*"))
def _requested(site: Site) -> list[str]:
return [path for path, _ in site.requests]
def _checks(capsys) -> list[str]:
return [line[len("CHECK "):] for line in capsys.readouterr().out.splitlines() if line.startswith("CHECK ")]
# --- without --scope --------------------------------------------------------
def test_without_scope_the_page_is_fetched_alone_and_the_offer_is_one_check(tree, module, capsys):
_fetch(module.url("/mod/"), name="mod", scope=False)
assert sorted(p.name for p in (tree / "incoming").iterdir()) == ["mod.html", "mod.md"]
assert (tree / "incoming/mod.html").read_bytes() == module.routes["/mod/"][2]
assert _requested(module) == ["/mod/"]
lines = _checks(capsys)
assert len(lines) == 1
assert "3 subpage(s)" in lines[0] and "--scope" in lines[0]
# --- with --scope -----------------------------------------------------------
def test_scope_writes_a_folder_with_a_pair_per_page_and_the_manifest(tree, module):
before = datetime.datetime.now(datetime.timezone.utc)
_fetch(module.url("/mod/"), name="mod")
folder = tree / "incoming/mod"
assert sorted(p.name for p in folder.iterdir()) == [
"_scope.json", "a.html", "a.md", "b.html", "b.md", "c.html", "c.md", "mod.html", "mod.md",
]
assert [p.name for p in (tree / "incoming").iterdir()] == ["mod"]
for name, path in (("mod", "/mod/"), ("a", "/mod/a?v=1"), ("b", "/mod/b?v=1"), ("c", "/mod/c")):
assert (folder / f"{name}.html").read_bytes() == module.routes[path][2]
text = (folder / "a.md").read_text(encoding="utf-8")
assert "a text" in text and "derived_from: a.html" in text
manifest = json.loads((folder / "_scope.json").read_text(encoding="utf-8"))
assert manifest["scope"] == 1 and manifest["url"] == module.url("/mod/")
assert [(p["name"], p["role"]) for p in manifest["pages"]] == [
("mod", "index"), ("a", "subpage"), ("b", "subpage"), ("c", "subpage"),
]
for page in manifest["pages"]:
html = (folder / f"{page['name']}.html").read_bytes()
assert page["sha256"] == hashlib.sha256(html).hexdigest()
assert page["files"] == [f"{page['name']}.html", f"{page['name']}.md"]
stamp = datetime.datetime.strptime(page["retrieved"], "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=datetime.timezone.utc)
assert before - datetime.timedelta(seconds=2) <= stamp <= datetime.datetime.now(datetime.timezone.utc)
assert [p["url"] for p in manifest["pages"][1:]] == [
module.url("/mod/a?v=1"), module.url("/mod/b?v=1"), module.url("/mod/c"),
]
def test_only_the_urls_the_handler_names_are_requested(tree, module):
_fetch(module.url("/mod/"), name="mod")
paths = _requested(module)
assert sorted(paths) == sorted(["/mod/", "/robots.txt", "/mod/a?v=1", "/mod/b?v=1", "/mod/c"])
assert not {"/mod/hidden", "/mod/foreign", "/elsewhere/x"} & set(paths)
def test_the_requests_identify_the_tool(tree, module):
_fetch(module.url("/mod/"), name="mod")
assert module.requests and all(agent.startswith("chemenu-wikitool/") for _, agent in module.requests)
def test_the_folder_is_accepted_by_raw_accept_unchanged(tree, module):
_fetch(module.url("/mod/"), name="mod")
snapshot = {p.name: p.read_bytes() for p in (tree / "incoming/mod").iterdir()}
raw_accept_command(
files=[tree / "incoming/mod"], fidelity="published", authority="reporting",
page=None, replaces=None, dry_run=False,
)
accepted = [p for p in (tree / "raw").rglob("*") if p.is_dir() and p.name == "mod"]
assert len(accepted) == 1
assert {p.name: p.read_bytes() for p in accepted[0].iterdir()} == snapshot
assert not (tree / "incoming/mod").exists()
def test_the_pause_is_taken_before_each_subpage(tree, module, monkeypatch):
write_settings(tree, monkeypatch, sites={"pause_seconds": 0.25})
slept: list[float] = []
monkeypatch.setattr("chemenu.commands.raw.fetch.time.sleep", slept.append)
_fetch(module.url("/mod/"), name="mod")
assert slept == [0.25, 0.25, 0.25]
# --- refusals: nothing is written --------------------------------------------
def test_robots_forbidding_a_subpage_refuses_the_fetch_and_names_it(tree, module, capsys):
module.routes["/robots.txt"] = (200, {"Content-Type": "text/plain"}, b"User-agent: *\nDisallow: /mod/b\n")
before = _snapshot(tree)
_refused(module.url("/mod/"), name="mod")
assert _snapshot(tree) == before
assert module.url("/mod/b?v=1") in capsys.readouterr().out.replace("\n", "")
assert not {"/mod/a?v=1", "/mod/b?v=1", "/mod/c"} & set(_requested(module))
def test_a_robots_txt_for_another_agent_does_not_apply(tree, module):
module.routes["/robots.txt"] = (
200, {"Content-Type": "text/plain"}, b"User-agent: somebodyelse\nDisallow: /\n",
)
_fetch(module.url("/mod/"), name="mod")
assert (tree / "incoming/mod/c.html").is_file()
def test_a_missing_robots_txt_allows_everything(tree, module):
assert "/robots.txt" not in module.routes
_fetch(module.url("/mod/"), name="mod")
assert (tree / "incoming/mod/c.html").is_file()
def test_a_robots_txt_that_cannot_be_read_refuses_the_fetch(tree, module, capsys):
module.routes["/robots.txt"] = (503, {}, b"later")
before = _snapshot(tree)
_refused(module.url("/mod/"), name="mod")
assert _snapshot(tree) == before
assert "robots.txt" in capsys.readouterr().out
def test_robots_does_not_apply_to_the_page_without_scope(tree, module):
module.routes["/robots.txt"] = (200, {"Content-Type": "text/plain"}, b"User-agent: *\nDisallow: /\n")
_fetch(module.url("/mod/"), name="mod", scope=False)
assert (tree / "incoming/mod.html").is_file()
assert "/robots.txt" not in _requested(module)
def test_a_failing_third_fetch_leaves_incoming_as_it_was(tree, module):
module.routes["/mod/b?v=1"] = (500, {}, b"broken")
(tree / "incoming/other.md").write_text("other", encoding="utf-8")
before = _snapshot(tree)
_refused(module.url("/mod/"), name="mod")
assert _snapshot(tree) == before
assert "/mod/b?v=1" in _requested(module)
assert "/mod/c" not in _requested(module)
def test_a_subpage_that_is_not_html_refuses_the_fetch(tree, module):
module.routes["/mod/c"] = (200, {"Content-Type": "application/pdf"}, b"%PDF-1.7")
before = _snapshot(tree)
_refused(module.url("/mod/"), name="mod")
assert _snapshot(tree) == before
def test_more_subpages_than_the_maximum_refuse_before_the_first_subpage(tree, module, monkeypatch, capsys):
write_settings(tree, monkeypatch, sites={"max_pages": 2})
before = _snapshot(tree)
_refused(module.url("/mod/"), name="mod")
assert _snapshot(tree) == before
assert _requested(module) == ["/mod/"]
assert "max_pages" in " ".join(capsys.readouterr().out.split())
def test_a_raised_maximum_takes_effect(tree, module, monkeypatch):
write_settings(tree, monkeypatch, sites={"max_pages": 4})
_fetch(module.url("/mod/"), name="mod")
assert (tree / "incoming/mod/c.md").is_file()
def test_an_existing_folder_is_never_overwritten(tree, module):
(tree / "incoming/mod").mkdir()
(tree / "incoming/mod/mine.md").write_text("mine", encoding="utf-8")
before = _snapshot(tree)
_refused(module.url("/mod/"), name="mod")
assert _snapshot(tree) == before
assert not {"/mod/a?v=1", "/mod/b?v=1", "/mod/c"} & set(_requested(module))
def test_a_path_over_the_budget_is_refused_before_a_subpage_is_requested(tree, module):
before = _snapshot(tree)
_refused(module.url("/mod/"), name="m" * 140)
assert _snapshot(tree) == before
assert "/mod/a?v=1" not in _requested(module)
def test_a_page_whose_handler_offers_no_subpages_is_refused(tree, module):
module.html("/plain", _sub("plain"))
before = _snapshot(tree)
_refused(module.url("/plain"), name="plain")
assert _snapshot(tree) == before
def test_an_index_without_subpages_is_refused(tree, module):
module.html("/mod/", b"<html><body><main><p>No table at all, only text.</p></main></body></html>")
before = _snapshot(tree)
_refused(module.url("/mod/"), name="mod")
assert _snapshot(tree) == before
def test_scope_does_not_go_with_html(tree):
saved = tree / "incoming/mod.html"
saved.write_bytes(b"<p>x</p>")
with pytest.raises(typer.Exit) as excinfo:
raw_fetch_command(url=None, html=saved, source_url="http://127.0.0.1/mod/", name=None, scope=True)
assert excinfo.value.exit_code == 1
# --- the building block as data ---------------------------------------------
def test_the_stack_handler_for_a_module_offers_its_cmdlet_pages_as_subpages():
special = next(s for s in sites.all_sites() if s.name == "learn-microsoft-powershell-module")
assert special.subpages is not None and special.subpages.prefix == "{page}"
html = (
'<table><tr><td><a data-linktype="relative-path" href="get-x?view=v">x</a></td></tr>'
'<tr><td><a data-linktype="absolute-path" href="/en-us/other">y</a></td></tr></table>'
'<a data-linktype="relative-path" href="get-y">outside the table</a>'
)
assert special.subpages.urls(html, "https://learn.microsoft.com/en-us/powershell/module/m/?view=v") == [
"https://learn.microsoft.com/en-us/powershell/module/m/get-x?view=v",
]
@pytest.mark.parametrize("subpages", [
{},
{"links": {}},
{"links": {"tag": "a"}, "prefix": "relative/"},
{"links": {"tag": "a"}, "extra": 1},
])
def test_a_malformed_subpages_block_is_a_rejected_file(subpages):
from chemenu.sites import data
with pytest.raises(ValueError):
data.build("x", {"host": "example.org", "summary": "s", "subpages": subpages}, "instance")
def test_a_literal_prefix_replaces_the_page_path():
block = sites.Subpages(web_capture.Rule(tag="a"), "/docs/ref/")
html = '<a href="/docs/ref/one">1</a><a href="/docs/guide/two">2</a><a href="/docs/ref/">self</a>'
assert block.urls(html, "https://example.org/docs/index") == ["https://example.org/docs/ref/one"]
def test_the_new_settings_have_their_defaults_and_refuse_nonsense():
assert check(SITES, {}).values["max_pages"] == 200
assert check(SITES, {}).values["pause_seconds"] == 1
assert check(SITES, {"max_pages": 0}).problems
assert check(SITES, {"pause_seconds": -1}).problems
assert not check(SITES, {"pause_seconds": 0}).problems
+1 -1
View File
@@ -60,7 +60,7 @@ def _handler(root: Path, name: str, content) -> Path:
def _fetch(url, name=None, html=None):
return raw_fetch_command(url=None if html else url, html=html, source_url=url if html else None, name=name)
return raw_fetch_command(url=None if html else url, html=html, source_url=url if html else None, name=name, scope=False)
def _refused(url, **kwargs) -> None:
+7 -6
View File
@@ -57,8 +57,8 @@ def served(monkeypatch):
return asked
def _fetch(url=None, html=None, source_url=None, name=None):
return raw_fetch_command(url=url, html=html, source_url=source_url, name=name)
def _fetch(url=None, html=None, source_url=None, name=None, scope=False):
return raw_fetch_command(url=url, html=html, source_url=source_url, name=name, scope=scope)
def _refused(**kwargs) -> None:
@@ -130,13 +130,13 @@ def test_the_stem_comes_from_the_standard_form(tree, served):
assert (tree / "incoming/get-oslicenseinfo.md").is_file()
def test_the_checks_name_the_source_and_the_cmdlet_pages(tree, served, capsys):
def test_the_checks_name_the_source_and_the_subpages(tree, served, capsys):
_fetch(MODULE_URL, name="oslicense")
lines = _checks(capsys)
assert len(lines) == 2
assert SOURCE_URL in lines[0]
assert "11 cmdlet page(s)" in lines[1]
assert "index with 11 subpage(s)" in lines[1] and "--scope" in lines[1]
def test_a_page_of_the_general_handler_has_only_the_source_check(tree, served, capsys):
@@ -188,7 +188,7 @@ def test_html_applies_the_rules_but_keeps_the_url_as_typed(tree, monkeypatch, ca
assert "requested_url" not in header
assert header["site"] == "learn-microsoft-powershell-module"
assert "Exit editor mode" not in text and "# OSLicense Module" in text
assert len(_checks(capsys)) == 2
assert len(_checks(capsys)) == 1
# --- the instance's part -----------------------------------------------------
@@ -286,7 +286,8 @@ def test_a_special_handler_inherits_from_the_general_one():
assert special.drop == general.drop
assert special.rewrites == general.rewrites
assert special.checks[: len(general.checks)] == general.checks
assert len(special.checks) == len(general.checks) + 1
assert special.checks == general.checks
assert special.subpages is not None and general.subpages is None
def test_a_duplicate_host_and_path_is_a_registration_error(registry):
+42 -2
View File
@@ -25,6 +25,7 @@ import mimetypes
import re
import time
import unicodedata
import urllib.robotparser
import urllib.error
import urllib.parse
import urllib.request
@@ -66,6 +67,14 @@ class Response:
return media_type(self.content_type)
class HttpError(BackendError):
"""A server answered with an error status; `status` is the number."""
def __init__(self, message: str, status: int):
super().__init__(message)
self.status = status
def media_type(content_type: str) -> str:
if not content_type.strip():
return ""
@@ -140,7 +149,7 @@ def fetch(
body=b"".join(chunks),
)
except urllib.error.HTTPError as exc:
raise BackendError(f"{url} answered HTTP {exc.code} {exc.reason}.") from exc
raise HttpError(f"{url} answered HTTP {exc.code} {exc.reason}.", exc.code) from exc
except urllib.error.URLError as exc:
if isinstance(exc.reason, TimeoutError):
raise BackendError(f"{url} did not answer within {timeout:g} s.") from exc
@@ -598,8 +607,12 @@ def prune(root: _Element, rules: "tuple[Rule, ...]") -> None:
root.children = kept
def matching(document: _Element, rule: Rule) -> "list[_Element]":
return [element for element in document.iter() if rule.matches(element)]
def count_matches(document: _Element, rule: Rule) -> int:
return sum(1 for element in document.iter() if rule.matches(element))
return len(matching(document, rule))
def meta_content(document: _Element, name: str) -> str:
@@ -622,6 +635,33 @@ def derive_text(html: str, base_url: str, drop: "tuple[Rule, ...]" = ()) -> str:
return _join(blocks)
# --- robots.txt -------------------------------------------------------------
def robots_refusals(
urls: "list[str]", user_agent: str, timeout: float = TIMEOUT_SECONDS, max_bytes: int = MAX_BYTES
) -> "list[str]":
"""The URLs among `urls` (all of one host) that the host's `robots.txt`
forbids this tool. A missing `robots.txt` (any 4xx) forbids nothing; one
that cannot be read at all (a 5xx, no answer) is a `BackendError`, because
"unknown" is not "allowed"."""
if not urls:
return []
parts = urllib.parse.urlsplit(urls[0])
robots_url = f"{parts.scheme}://{parts.netloc}/robots.txt"
try:
body = fetch(robots_url, user_agent, timeout=timeout, max_bytes=max_bytes).body
except HttpError as exc:
if 400 <= exc.status < 500:
return []
raise BackendError(f"{robots_url} could not be read ({exc}) - the subpages are not fetched without it.") from exc
except BackendError as exc:
raise BackendError(f"{robots_url} could not be read ({exc}) - the subpages are not fetched without it.") from exc
parser = urllib.robotparser.RobotFileParser()
parser.parse(body.decode("utf-8", errors="replace").splitlines())
return [url for url in urls if not parser.can_fetch(user_agent, url)]
# --- the capture file -------------------------------------------------------