crawl4tools 1.0.0b1__py3-none-any.whl
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.
- crawl4tools/__init__.py +10 -0
- crawl4tools/cli/__init__.py +1 -0
- crawl4tools/cli/main.py +394 -0
- crawl4tools/cli/output.py +71 -0
- crawl4tools/cli/report.py +48 -0
- crawl4tools/engine/__init__.py +100 -0
- crawl4tools/engine/classify.py +152 -0
- crawl4tools/engine/errors.py +266 -0
- crawl4tools/engine/fetcher.py +620 -0
- crawl4tools/engine/interception.py +216 -0
- crawl4tools/engine/models.py +116 -0
- crawl4tools/engine/naming.py +197 -0
- crawl4tools/engine/pdf.py +53 -0
- crawl4tools/engine/probe.py +149 -0
- crawl4tools/engine/proxy.py +123 -0
- crawl4tools/i18n.py +176 -0
- crawl4tools/locale/ja/LC_MESSAGES/crawl4tools.mo +0 -0
- crawl4tools/locale/ja/LC_MESSAGES/crawl4tools.po +536 -0
- crawl4tools/py.typed +0 -0
- crawl4tools/server/__init__.py +28 -0
- crawl4tools/server/cli_options.py +262 -0
- crawl4tools/server/config.py +91 -0
- crawl4tools/server/host.py +284 -0
- crawl4tools/server/loader.py +239 -0
- crawl4tools/server/mcp_main.py +230 -0
- crawl4tools/server/mcp_server.py +445 -0
- crawl4tools/server/results.py +258 -0
- crawl4tools/server/server_main.py +360 -0
- crawl4tools/server/settings.py +126 -0
- crawl4tools-1.0.0b1.dist-info/METADATA +327 -0
- crawl4tools-1.0.0b1.dist-info/RECORD +35 -0
- crawl4tools-1.0.0b1.dist-info/WHEEL +4 -0
- crawl4tools-1.0.0b1.dist-info/entry_points.txt +4 -0
- crawl4tools-1.0.0b1.dist-info/licenses/LICENSE +202 -0
- crawl4tools-1.0.0b1.dist-info/licenses/NOTICE +4 -0
crawl4tools/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""crawl4tools: web fetching tools built on crawl4ai."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("crawl4tools")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only when running from an uninstalled tree
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command line interface (crawl4cli)."""
|
crawl4tools/cli/main.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Entry point of the crawl4cli command.
|
|
2
|
+
|
|
3
|
+
:func:`build_command` builds the click command for one translator, which
|
|
4
|
+
fixes the language of the help texts and of the argument errors. Messages
|
|
5
|
+
printed while running use the language chosen by ``--lang``, then
|
|
6
|
+
``CRAWL4CLI_LANG``, then the locale, else English. The console script
|
|
7
|
+
:func:`entry` makes the same choice from the command line and the
|
|
8
|
+
environment before building the command, so ``--help`` is in that language
|
|
9
|
+
too. The ``note:`` / ``error:`` / ``saved:`` / ``done:`` / ``failed:``
|
|
10
|
+
prefixes, the ``--version`` text and the fetched document never change.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
from crawl4tools import __version__
|
|
25
|
+
from crawl4tools.cli.output import write_outcome
|
|
26
|
+
from crawl4tools.cli.report import error_line, exit_code, note_line, summary_lines
|
|
27
|
+
from crawl4tools.engine import (
|
|
28
|
+
Fetcher,
|
|
29
|
+
FetchOptions,
|
|
30
|
+
FetchOutcome,
|
|
31
|
+
NameAllocator,
|
|
32
|
+
OutputFormat,
|
|
33
|
+
dedupe_urls,
|
|
34
|
+
normalize_proxy,
|
|
35
|
+
validate_url,
|
|
36
|
+
)
|
|
37
|
+
from crawl4tools.i18n import (
|
|
38
|
+
ENGLISH,
|
|
39
|
+
SUPPORTED_LANGUAGES,
|
|
40
|
+
Translator,
|
|
41
|
+
get_translator,
|
|
42
|
+
language_from_argv,
|
|
43
|
+
render_exception,
|
|
44
|
+
resolve_language,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
CRAWL4AI_ATTRIBUTION = (
|
|
48
|
+
"This product includes software developed by UncleCode (https://x.com/unclecode) "
|
|
49
|
+
"as part of the Crawl4AI project (https://github.com/unclecode/crawl4ai)."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Loggers that crawl4ai's HTTP dependencies use directly; silenced unless
|
|
53
|
+
# --verbose is given so ordinary runs stay quiet on stderr.
|
|
54
|
+
_NOISY_LOGGERS = ("httpx", "httpcore")
|
|
55
|
+
|
|
56
|
+
# The variable naming the message language. click also reads it for --lang
|
|
57
|
+
# through auto_envvar_prefix="CRAWL4CLI", and rejects unsupported values.
|
|
58
|
+
_LANG_ENVVAR = "CRAWL4CLI_LANG"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _crawl4ai_version() -> str:
|
|
62
|
+
try:
|
|
63
|
+
return version("crawl4ai")
|
|
64
|
+
except PackageNotFoundError:
|
|
65
|
+
return "unknown"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def version_text() -> str:
|
|
69
|
+
"""Return the text printed by ``crawl4cli --version``."""
|
|
70
|
+
return f"crawl4cli {__version__} (crawl4ai {_crawl4ai_version()})\n{CRAWL4AI_ATTRIBUTION}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
|
74
|
+
if not value or ctx.resilient_parsing:
|
|
75
|
+
return
|
|
76
|
+
click.echo(version_text())
|
|
77
|
+
ctx.exit(0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_fetcher(options: FetchOptions) -> Fetcher:
|
|
81
|
+
"""Build the :class:`Fetcher` used to run a batch of fetches.
|
|
82
|
+
|
|
83
|
+
A thin, monkeypatchable seam: tests replace this to inject a Fetcher
|
|
84
|
+
built with fake crawler/HTTP client factories instead of real ones.
|
|
85
|
+
"""
|
|
86
|
+
return Fetcher(options)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _fetch_all(
|
|
90
|
+
options: FetchOptions, urls: list[str], concurrency: int
|
|
91
|
+
) -> list[FetchOutcome]:
|
|
92
|
+
async with create_fetcher(options) as fetcher:
|
|
93
|
+
return await fetcher.fetch_many(urls, concurrency)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _message_language(explicit: str | None) -> str:
|
|
97
|
+
"""Return the message language: *explicit*, ``CRAWL4CLI_LANG``, the locale, else ``en``."""
|
|
98
|
+
return resolve_language(explicit, env=os.environ, envvar=_LANG_ENVVAR, follow_locale=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run(
|
|
102
|
+
runtime: Translator,
|
|
103
|
+
urls: tuple[str, ...],
|
|
104
|
+
options: FetchOptions,
|
|
105
|
+
*,
|
|
106
|
+
output: Path | None,
|
|
107
|
+
output_dir: Path,
|
|
108
|
+
concurrency: int,
|
|
109
|
+
quiet: bool,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Fetch *urls*, write the results, and exit; messages are translated with *runtime*.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
click.UsageError: if ``--output`` is given with more than one URL.
|
|
115
|
+
"""
|
|
116
|
+
unique_urls, duplicate_urls = dedupe_urls(urls)
|
|
117
|
+
|
|
118
|
+
if output is not None and len(unique_urls) > 1:
|
|
119
|
+
raise click.UsageError(
|
|
120
|
+
runtime.gettext(
|
|
121
|
+
"--output can only be used with a single URL; use --output-dir for several URLs"
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not quiet:
|
|
126
|
+
for url in duplicate_urls:
|
|
127
|
+
message = runtime.gettext("duplicate URL ignored: {url}").format(url=url)
|
|
128
|
+
click.echo(f"note: {message}", err=True)
|
|
129
|
+
|
|
130
|
+
if not options.verbose:
|
|
131
|
+
for name in _NOISY_LOGGERS:
|
|
132
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
133
|
+
|
|
134
|
+
outcomes = asyncio.run(_fetch_all(options, unique_urls, concurrency))
|
|
135
|
+
|
|
136
|
+
allocator = NameAllocator(output_dir)
|
|
137
|
+
single = len(unique_urls) == 1
|
|
138
|
+
succeeded = 0
|
|
139
|
+
failed_urls: list[str] = []
|
|
140
|
+
|
|
141
|
+
for url, outcome in zip(unique_urls, outcomes, strict=True):
|
|
142
|
+
if not quiet:
|
|
143
|
+
for note in outcome.notes:
|
|
144
|
+
click.echo(note_line(url, note, runtime), err=True)
|
|
145
|
+
|
|
146
|
+
if not outcome.ok:
|
|
147
|
+
click.echo(error_line(outcome, runtime), err=True)
|
|
148
|
+
failed_urls.append(url)
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
to_stdout = single and output is None and outcome.text is not None
|
|
152
|
+
try:
|
|
153
|
+
saved = write_outcome(
|
|
154
|
+
outcome,
|
|
155
|
+
url,
|
|
156
|
+
output_path=output if single else None,
|
|
157
|
+
directory=output_dir,
|
|
158
|
+
allocator=allocator,
|
|
159
|
+
to_stdout=to_stdout,
|
|
160
|
+
)
|
|
161
|
+
except OSError as exc:
|
|
162
|
+
# Keep this msgid as it is: crawl4mcp is to report write failures with it too.
|
|
163
|
+
message = runtime.gettext("could not write {path}: {reason}").format(
|
|
164
|
+
path=exc.filename, reason=exc.strerror
|
|
165
|
+
)
|
|
166
|
+
click.echo(f"error: {message}", err=True)
|
|
167
|
+
failed_urls.append(url)
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
succeeded += 1
|
|
171
|
+
if saved is not None and not quiet:
|
|
172
|
+
click.echo(f"saved: {url} -> {saved}", err=True)
|
|
173
|
+
|
|
174
|
+
if len(unique_urls) > 1 and (failed_urls or not quiet):
|
|
175
|
+
for line in summary_lines(succeeded, failed_urls, runtime):
|
|
176
|
+
click.echo(line, err=True)
|
|
177
|
+
|
|
178
|
+
sys.exit(exit_code(len(failed_urls)))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_command(t: Translator) -> click.Command:
|
|
182
|
+
"""Return the crawl4cli click command with its texts translated by *t*.
|
|
183
|
+
|
|
184
|
+
*t* translates what is fixed when the command is built: the command and
|
|
185
|
+
option help and the errors of the URL and ``--proxy`` arguments. The
|
|
186
|
+
messages printed while running follow the language resolved at run
|
|
187
|
+
time from ``--lang``, ``CRAWL4CLI_LANG`` and the locale instead.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def validate_urls(
|
|
191
|
+
_ctx: click.Context, _param: click.Parameter, value: tuple[str, ...]
|
|
192
|
+
) -> tuple[str, ...]:
|
|
193
|
+
try:
|
|
194
|
+
return tuple(validate_url(url) for url in value)
|
|
195
|
+
except ValueError as exc:
|
|
196
|
+
raise click.BadParameter(render_exception(exc, t)) from exc
|
|
197
|
+
|
|
198
|
+
def validate_proxy(
|
|
199
|
+
_ctx: click.Context, _param: click.Parameter, value: str | None
|
|
200
|
+
) -> str | None:
|
|
201
|
+
if value is None:
|
|
202
|
+
return None
|
|
203
|
+
try:
|
|
204
|
+
return normalize_proxy(value)
|
|
205
|
+
except ValueError as exc:
|
|
206
|
+
raise click.BadParameter(render_exception(exc, t)) from exc
|
|
207
|
+
|
|
208
|
+
# The command is still named "main" (click derives the name from the
|
|
209
|
+
# function), as it was before the command was built by this factory.
|
|
210
|
+
@click.command(
|
|
211
|
+
help=t.gettext("Download web pages as Markdown and other formats."),
|
|
212
|
+
context_settings={"auto_envvar_prefix": "CRAWL4CLI"},
|
|
213
|
+
)
|
|
214
|
+
@click.argument("urls", nargs=-1, required=True, callback=validate_urls)
|
|
215
|
+
@click.option(
|
|
216
|
+
"-f",
|
|
217
|
+
"--format",
|
|
218
|
+
"format",
|
|
219
|
+
type=click.Choice([fmt.value for fmt in OutputFormat], case_sensitive=False),
|
|
220
|
+
default=OutputFormat.MARKDOWN.value,
|
|
221
|
+
show_default=True,
|
|
222
|
+
help=t.gettext("Output format."),
|
|
223
|
+
)
|
|
224
|
+
@click.option(
|
|
225
|
+
"-o",
|
|
226
|
+
"--output",
|
|
227
|
+
"output",
|
|
228
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
229
|
+
default=None,
|
|
230
|
+
help=t.gettext("Write the (single) fetched URL to this file instead of stdout."),
|
|
231
|
+
)
|
|
232
|
+
@click.option(
|
|
233
|
+
"-d",
|
|
234
|
+
"--output-dir",
|
|
235
|
+
"output_dir",
|
|
236
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
237
|
+
default=".",
|
|
238
|
+
show_default=True,
|
|
239
|
+
help=t.gettext("Directory to save fetched URLs into."),
|
|
240
|
+
)
|
|
241
|
+
@click.option(
|
|
242
|
+
"--proxy",
|
|
243
|
+
"proxy",
|
|
244
|
+
default=None,
|
|
245
|
+
callback=validate_proxy,
|
|
246
|
+
help=t.gettext("Proxy URL (http, https, or socks5); e.g. socks5://host:1080."),
|
|
247
|
+
)
|
|
248
|
+
@click.option(
|
|
249
|
+
"--fallback/--no-fallback",
|
|
250
|
+
"fallback",
|
|
251
|
+
default=True,
|
|
252
|
+
help=t.gettext("Retry without the proxy when the proxy itself appears to be at fault."),
|
|
253
|
+
)
|
|
254
|
+
@click.option(
|
|
255
|
+
"-j",
|
|
256
|
+
"--concurrency",
|
|
257
|
+
"concurrency",
|
|
258
|
+
type=click.IntRange(min=1),
|
|
259
|
+
default=3,
|
|
260
|
+
show_default=True,
|
|
261
|
+
help=t.gettext("Maximum number of URLs fetched at once."),
|
|
262
|
+
)
|
|
263
|
+
@click.option(
|
|
264
|
+
"--timeout",
|
|
265
|
+
"timeout",
|
|
266
|
+
type=click.FloatRange(min=0, min_open=True),
|
|
267
|
+
default=60.0,
|
|
268
|
+
show_default=True,
|
|
269
|
+
help=t.gettext("Per-URL timeout in seconds."),
|
|
270
|
+
)
|
|
271
|
+
@click.option(
|
|
272
|
+
"--citations",
|
|
273
|
+
"citations",
|
|
274
|
+
is_flag=True,
|
|
275
|
+
default=False,
|
|
276
|
+
help=t.gettext("Add Markdown citations."),
|
|
277
|
+
)
|
|
278
|
+
@click.option(
|
|
279
|
+
"--fit",
|
|
280
|
+
"fit",
|
|
281
|
+
is_flag=True,
|
|
282
|
+
default=False,
|
|
283
|
+
help=t.gettext(
|
|
284
|
+
"Keep only the main content (drop menus, footers, and the like) in the Markdown."
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
@click.option(
|
|
288
|
+
"--no-links",
|
|
289
|
+
"no_links",
|
|
290
|
+
is_flag=True,
|
|
291
|
+
default=False,
|
|
292
|
+
help=t.gettext("Strip links from the Markdown."),
|
|
293
|
+
)
|
|
294
|
+
@click.option(
|
|
295
|
+
"--no-images",
|
|
296
|
+
"no_images",
|
|
297
|
+
is_flag=True,
|
|
298
|
+
default=False,
|
|
299
|
+
help=t.gettext("Strip images from the Markdown."),
|
|
300
|
+
)
|
|
301
|
+
@click.option(
|
|
302
|
+
"-q",
|
|
303
|
+
"--quiet",
|
|
304
|
+
"quiet",
|
|
305
|
+
is_flag=True,
|
|
306
|
+
default=False,
|
|
307
|
+
help=t.gettext("Suppress progress and note lines."),
|
|
308
|
+
)
|
|
309
|
+
@click.option(
|
|
310
|
+
"-v",
|
|
311
|
+
"--verbose",
|
|
312
|
+
"verbose",
|
|
313
|
+
is_flag=True,
|
|
314
|
+
default=False,
|
|
315
|
+
help=t.gettext("Enable verbose engine logging."),
|
|
316
|
+
)
|
|
317
|
+
@click.option(
|
|
318
|
+
"--lang",
|
|
319
|
+
"lang",
|
|
320
|
+
type=click.Choice(list(SUPPORTED_LANGUAGES)),
|
|
321
|
+
default=None,
|
|
322
|
+
help=t.gettext(
|
|
323
|
+
"Language of messages: en or ja. "
|
|
324
|
+
"Defaults to the locale (LANGUAGE, LC_ALL, LC_MESSAGES, LANG), else en."
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
@click.option(
|
|
328
|
+
"--version",
|
|
329
|
+
is_flag=True,
|
|
330
|
+
expose_value=False,
|
|
331
|
+
is_eager=True,
|
|
332
|
+
callback=_print_version,
|
|
333
|
+
help=t.gettext("Show the version and exit."),
|
|
334
|
+
)
|
|
335
|
+
def main(
|
|
336
|
+
urls: tuple[str, ...],
|
|
337
|
+
format: str,
|
|
338
|
+
output: Path | None,
|
|
339
|
+
output_dir: Path,
|
|
340
|
+
proxy: str | None,
|
|
341
|
+
fallback: bool,
|
|
342
|
+
concurrency: int,
|
|
343
|
+
timeout: float,
|
|
344
|
+
citations: bool,
|
|
345
|
+
fit: bool,
|
|
346
|
+
no_links: bool,
|
|
347
|
+
no_images: bool,
|
|
348
|
+
quiet: bool,
|
|
349
|
+
verbose: bool,
|
|
350
|
+
lang: str | None,
|
|
351
|
+
) -> None:
|
|
352
|
+
# No docstring: the help comes from help= above so that it is translated.
|
|
353
|
+
runtime = get_translator(_message_language(lang))
|
|
354
|
+
options = FetchOptions(
|
|
355
|
+
format=OutputFormat(format.lower()),
|
|
356
|
+
proxy=proxy,
|
|
357
|
+
fallback=fallback,
|
|
358
|
+
timeout_s=timeout,
|
|
359
|
+
citations=citations,
|
|
360
|
+
fit=fit,
|
|
361
|
+
ignore_links=no_links,
|
|
362
|
+
ignore_images=no_images,
|
|
363
|
+
verbose=verbose,
|
|
364
|
+
)
|
|
365
|
+
_run(
|
|
366
|
+
runtime,
|
|
367
|
+
urls,
|
|
368
|
+
options,
|
|
369
|
+
output=output,
|
|
370
|
+
output_dir=output_dir,
|
|
371
|
+
concurrency=concurrency,
|
|
372
|
+
quiet=quiet,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return main
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
main = build_command(ENGLISH)
|
|
379
|
+
"""The command with English help, for importing and testing.
|
|
380
|
+
|
|
381
|
+
Its run-time messages still follow ``--lang``, ``CRAWL4CLI_LANG`` and the
|
|
382
|
+
locale; only the help and the argument errors are always English.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def entry() -> None:
|
|
387
|
+
"""Run crawl4cli as the console script.
|
|
388
|
+
|
|
389
|
+
The language is chosen from ``--lang`` in ``sys.argv``, then
|
|
390
|
+
``CRAWL4CLI_LANG``, then the locale, before the command is built, so the
|
|
391
|
+
help and the argument errors are in that language as well.
|
|
392
|
+
"""
|
|
393
|
+
lang = _message_language(language_from_argv(sys.argv[1:]))
|
|
394
|
+
build_command(get_translator(lang)).main(prog_name="crawl4cli")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Writing fetch outcomes to stdout or to disk.
|
|
2
|
+
|
|
3
|
+
Pure(ish) helpers with no click dependency for their core logic (writing to
|
|
4
|
+
stdout still goes through :func:`click.echo` so it interacts correctly with
|
|
5
|
+
click's output streams in tests), so they can be unit-tested directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from crawl4tools.engine import FetchOutcome, NameAllocator, filename_for
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def payload_bytes(outcome: FetchOutcome) -> bytes:
|
|
18
|
+
"""Return the raw bytes to write for *outcome*.
|
|
19
|
+
|
|
20
|
+
Binary payloads (``outcome.data``) take priority; text payloads are
|
|
21
|
+
encoded as UTF-8. Returns ``b""`` if the outcome carries neither.
|
|
22
|
+
"""
|
|
23
|
+
if outcome.data is not None:
|
|
24
|
+
return outcome.data
|
|
25
|
+
return (outcome.text or "").encode("utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_stdout(outcome: FetchOutcome) -> None:
|
|
29
|
+
"""Write ``outcome.text`` to stdout with exactly one trailing newline."""
|
|
30
|
+
text = outcome.text or ""
|
|
31
|
+
message = text if text.endswith("\n") else f"{text}\n"
|
|
32
|
+
click.echo(message, nl=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_outcome(
|
|
36
|
+
outcome: FetchOutcome,
|
|
37
|
+
url: str,
|
|
38
|
+
*,
|
|
39
|
+
output_path: Path | None,
|
|
40
|
+
directory: Path,
|
|
41
|
+
allocator: NameAllocator,
|
|
42
|
+
to_stdout: bool,
|
|
43
|
+
) -> Path | None:
|
|
44
|
+
"""Write *outcome* to its destination and return the saved path.
|
|
45
|
+
|
|
46
|
+
Exactly one of three destinations is used: stdout (when *to_stdout* is
|
|
47
|
+
True), *output_path* (when given), or an allocator-assigned path under
|
|
48
|
+
*directory*. Returns None when written to stdout.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
OSError: if the file could not be written. The exception's
|
|
52
|
+
``filename`` attribute is set to the path that was targeted.
|
|
53
|
+
"""
|
|
54
|
+
if to_stdout:
|
|
55
|
+
write_stdout(outcome)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
if output_path is not None:
|
|
59
|
+
target = output_path
|
|
60
|
+
else:
|
|
61
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
target = allocator.allocate(filename_for(url, outcome.suggested_extension))
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
target.write_bytes(payload_bytes(outcome))
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
if exc.filename is None:
|
|
69
|
+
exc.filename = str(target)
|
|
70
|
+
raise
|
|
71
|
+
return target
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Formatting of per-outcome and per-run CLI messages.
|
|
2
|
+
|
|
3
|
+
Every function takes the :data:`~crawl4tools.i18n.Translator` of the
|
|
4
|
+
message language. Only the message after the prefix is translated; the
|
|
5
|
+
``error:`` / ``note:`` / ``done:`` / ``failed:`` prefixes stay in English so
|
|
6
|
+
that scripts can keep matching them whatever the language is.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from crawl4tools.engine import FetchOutcome, Note
|
|
12
|
+
from crawl4tools.i18n import Translator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def error_line(outcome: FetchOutcome, t: Translator) -> str:
|
|
16
|
+
"""Return the ``error: ...`` line to print for a failed *outcome*.
|
|
17
|
+
|
|
18
|
+
The message is translated with *t*; without an error object, the
|
|
19
|
+
generic "fetch failed" message is used.
|
|
20
|
+
"""
|
|
21
|
+
if outcome.error is not None:
|
|
22
|
+
return f"error: {outcome.error.render(t)}"
|
|
23
|
+
message = t.gettext("fetch failed: {url}").format(url=outcome.url)
|
|
24
|
+
return f"error: {message}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def note_line(url: str, note: Note, t: Translator) -> str:
|
|
28
|
+
"""Return the ``note: <url>: ...`` line for a *note* about *url*, translated with *t*."""
|
|
29
|
+
return f"note: {url}: {note.render(t)}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def summary_lines(total_ok: int, failed_urls: list[str], t: Translator) -> list[str]:
|
|
33
|
+
"""Return the run summary: a ``done: ...`` line plus one per failed URL.
|
|
34
|
+
|
|
35
|
+
The counts in the ``done:`` line are translated with *t*; the
|
|
36
|
+
`` failed: <url>`` lines contain nothing to translate.
|
|
37
|
+
"""
|
|
38
|
+
counts = t.gettext("{succeeded} succeeded, {failed} failed").format(
|
|
39
|
+
succeeded=total_ok, failed=len(failed_urls)
|
|
40
|
+
)
|
|
41
|
+
lines = [f"done: {counts}"]
|
|
42
|
+
lines.extend(f" failed: {url}" for url in failed_urls)
|
|
43
|
+
return lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def exit_code(failed_count: int) -> int:
|
|
47
|
+
"""Return the process exit code for a run with *failed_count* failures."""
|
|
48
|
+
return 1 if failed_count else 0
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Shared fetch engine used by the CLI and the (future) servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from crawl4tools.engine.classify import build_error, classify_error_message, error_for_status
|
|
6
|
+
from crawl4tools.engine.errors import (
|
|
7
|
+
BlockedFetchError,
|
|
8
|
+
BrowserNotInstalledError,
|
|
9
|
+
ConnectionRefusedFetchError,
|
|
10
|
+
FetchError,
|
|
11
|
+
FetchTimeoutError,
|
|
12
|
+
HttpStatusError,
|
|
13
|
+
NameResolutionError,
|
|
14
|
+
NonHtmlContentError,
|
|
15
|
+
ProxyFetchError,
|
|
16
|
+
TlsFetchError,
|
|
17
|
+
)
|
|
18
|
+
from crawl4tools.engine.fetcher import (
|
|
19
|
+
CrawlerFactory,
|
|
20
|
+
CrawlerLike,
|
|
21
|
+
Fetcher,
|
|
22
|
+
build_run_config,
|
|
23
|
+
default_crawler_factory,
|
|
24
|
+
)
|
|
25
|
+
from crawl4tools.engine.models import (
|
|
26
|
+
ContentKind,
|
|
27
|
+
FailureKind,
|
|
28
|
+
FetchOptions,
|
|
29
|
+
FetchOutcome,
|
|
30
|
+
Note,
|
|
31
|
+
OutputFormat,
|
|
32
|
+
)
|
|
33
|
+
from crawl4tools.engine.naming import (
|
|
34
|
+
InvalidUrlError,
|
|
35
|
+
NameAllocator,
|
|
36
|
+
dedupe_urls,
|
|
37
|
+
extension_for,
|
|
38
|
+
filename_for,
|
|
39
|
+
validate_url,
|
|
40
|
+
)
|
|
41
|
+
from crawl4tools.engine.pdf import PdfConversionError, pdf_bytes_to_markdown, pdf_to_markdown
|
|
42
|
+
from crawl4tools.engine.probe import HttpClientFactory, ProbeResult, default_http_client, probe
|
|
43
|
+
from crawl4tools.engine.proxy import (
|
|
44
|
+
FALLBACK_KINDS,
|
|
45
|
+
FALLBACK_STATUS_CODES,
|
|
46
|
+
SUPPORTED_SCHEMES,
|
|
47
|
+
ProxyUrlError,
|
|
48
|
+
is_socks,
|
|
49
|
+
normalize_proxy,
|
|
50
|
+
redact_proxy,
|
|
51
|
+
should_fallback,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"FALLBACK_KINDS",
|
|
56
|
+
"FALLBACK_STATUS_CODES",
|
|
57
|
+
"SUPPORTED_SCHEMES",
|
|
58
|
+
"BlockedFetchError",
|
|
59
|
+
"BrowserNotInstalledError",
|
|
60
|
+
"ConnectionRefusedFetchError",
|
|
61
|
+
"ContentKind",
|
|
62
|
+
"CrawlerFactory",
|
|
63
|
+
"CrawlerLike",
|
|
64
|
+
"FailureKind",
|
|
65
|
+
"FetchError",
|
|
66
|
+
"FetchOptions",
|
|
67
|
+
"FetchOutcome",
|
|
68
|
+
"FetchTimeoutError",
|
|
69
|
+
"Fetcher",
|
|
70
|
+
"HttpClientFactory",
|
|
71
|
+
"HttpStatusError",
|
|
72
|
+
"InvalidUrlError",
|
|
73
|
+
"NameAllocator",
|
|
74
|
+
"NameResolutionError",
|
|
75
|
+
"NonHtmlContentError",
|
|
76
|
+
"Note",
|
|
77
|
+
"OutputFormat",
|
|
78
|
+
"PdfConversionError",
|
|
79
|
+
"ProbeResult",
|
|
80
|
+
"ProxyFetchError",
|
|
81
|
+
"ProxyUrlError",
|
|
82
|
+
"TlsFetchError",
|
|
83
|
+
"build_error",
|
|
84
|
+
"build_run_config",
|
|
85
|
+
"classify_error_message",
|
|
86
|
+
"dedupe_urls",
|
|
87
|
+
"default_crawler_factory",
|
|
88
|
+
"default_http_client",
|
|
89
|
+
"error_for_status",
|
|
90
|
+
"extension_for",
|
|
91
|
+
"filename_for",
|
|
92
|
+
"is_socks",
|
|
93
|
+
"normalize_proxy",
|
|
94
|
+
"pdf_bytes_to_markdown",
|
|
95
|
+
"pdf_to_markdown",
|
|
96
|
+
"probe",
|
|
97
|
+
"redact_proxy",
|
|
98
|
+
"should_fallback",
|
|
99
|
+
"validate_url",
|
|
100
|
+
]
|