Files
AI/참고/trafilatura-master/trafilatura/cli_utils.py
2026-05-12 19:40:31 +09:00

512 lines
17 KiB
Python

"""
Functions dedicated to command-line processing.
"""
try:
import gzip
HAS_GZIP = True
except ImportError:
HAS_GZIP = False
import logging
import random
import re
import string
import sys
import traceback
from base64 import urlsafe_b64encode
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from datetime import datetime
from functools import partial
from os import makedirs, path, stat, walk
from threading import RLock
from typing import Any, Generator, Optional, List, Set, Tuple
from courlan import UrlStore, extract_domain, get_base_url # validate_url
from trafilatura import spider
from .baseline import html2txt
from .core import extract
from .deduplication import generate_bow_hash
from .downloads import (
Response,
add_to_compressed_dict,
buffered_downloads,
buffered_response_downloads,
load_download_buffer
)
from .feeds import find_feed_urls
from .meta import reset_caches
from .settings import (
Extractor,
FILENAME_LEN,
MAX_FILES_PER_DIRECTORY,
args_to_extractor,
)
from .sitemaps import sitemap_search
from .utils import (
LANGID_FLAG,
URL_BLACKLIST_REGEX,
is_acceptable_length,
language_classifier,
make_chunks,
)
LOGGER = logging.getLogger(__name__)
random.seed(345) # make generated file names reproducible
CHAR_CLASS = string.ascii_letters + string.digits
STRIP_DIR = re.compile(r"[^/]+$")
STRIP_EXTENSION = re.compile(r"\.[a-z]{2,5}$")
CLEAN_XML = re.compile(r"<[^<]+?>")
INPUT_URLS_ARGS = ["URL", "crawl", "explore", "probe", "feed", "sitemap"]
EXTENSION_MAPPING = {
"csv": ".csv",
"json": ".json",
"xml": ".xml",
"xmltei": ".xml",
}
def load_input_urls(args: Any) -> List[str]:
"Read list of URLs to process or derive one from command-line arguments."
input_urls: List[str] = []
if args.input_file:
try:
# optional: errors='strict', buffering=1
with open(args.input_file, mode="r", encoding="utf-8") as inputfile:
input_urls.extend(line.strip() for line in inputfile)
except UnicodeDecodeError:
sys.exit("ERROR: system, file type or buffer encoding")
else:
for arg in INPUT_URLS_ARGS:
if getattr(args, arg):
input_urls = [getattr(args, arg)]
break
if not input_urls:
LOGGER.warning("No input provided")
# uniq URLs while preserving order (important)
return list(dict.fromkeys(input_urls))
def load_blacklist(filename: str) -> Set[str]:
"Read list of unwanted URLs."
with open(filename, "r", encoding="utf-8") as inputfh:
# if validate_url(url)[0] is True:
blacklist = {URL_BLACKLIST_REGEX.sub("", line.strip()) for line in inputfh}
return blacklist
def load_input_dict(args: Any) -> UrlStore:
"Read input list of URLs to process and build a domain-aware dictionary."
inputlist = load_input_urls(args)
# deduplicate, filter and convert to dict
return add_to_compressed_dict(
inputlist,
blacklist=args.blacklist,
compression=(args.sitemap and not args.list),
url_filter=args.url_filter,
verbose=args.verbose,
)
def check_outputdir_status(directory: str) -> bool:
"Check if the output directory is within reach and writable."
# check the directory status
if not path.exists(directory) or not path.isdir(directory):
try:
makedirs(directory, exist_ok=True)
except OSError:
# maybe the directory has already been created
# sleep(0.25)
# if not path.exists(directory) or not path.isdir(directory):
sys.stderr.write(
"ERROR: Destination directory cannot be created: " + directory + "\n"
)
# raise OSError()
return False
return True
def determine_counter_dir(dirname: str, c: int) -> str:
"Return a destination directory based on a file counter."
c_dir = str(int(c / MAX_FILES_PER_DIRECTORY) + 1) if c >= 0 else ""
return path.join(dirname, c_dir)
def get_writable_path(destdir: str, extension: str) -> Tuple[str, str]:
"Find a writable path and return it along with its random file name."
output_path = None
while output_path is None or path.exists(output_path):
# generate a random filename of the desired length
filename = "".join(random.choice(CHAR_CLASS) for _ in range(FILENAME_LEN))
output_path = path.join(destdir, filename + extension)
return output_path, filename
def generate_hash_filename(content: str) -> str:
"""Create a filename-safe string by hashing the given content
after deleting potential XML tags."""
return urlsafe_b64encode(generate_bow_hash(CLEAN_XML.sub("", content), 12)).decode()
def determine_output_path(
args: Any,
orig_filename: str,
content: str,
counter: int = -1,
new_filename: Optional[str] = None,
) -> Tuple[str, str]:
"Pick a directory based on selected options and a file name based on output type."
# determine extension, TXT by default
extension = EXTENSION_MAPPING.get(args.output_format, ".txt")
if args.keep_dirs:
# strip directory
original_dir = STRIP_DIR.sub("", orig_filename)
destination_dir = path.join(args.output_dir, original_dir)
# strip extension
filename = STRIP_EXTENSION.sub("", orig_filename)
else:
destination_dir = determine_counter_dir(args.output_dir, counter)
# use cryptographic hash on file contents to define name
filename = new_filename or generate_hash_filename(content)
output_path = path.join(destination_dir, filename + extension)
return output_path, destination_dir
def archive_html(htmlstring: str, args: Any, counter: int = -1) -> str:
"Write a copy of raw HTML in backup directory."
destination_directory = determine_counter_dir(args.backup_dir, counter)
output_path, filename = get_writable_path(destination_directory, ".html.gz")
# check the directory status
if check_outputdir_status(destination_directory) is True and HAS_GZIP:
# write
with gzip.open(output_path, "wb") as outputfile:
outputfile.write(htmlstring.encode("utf-8"))
return filename
def write_result(
result: Optional[str],
args: Any,
orig_filename: str = "",
counter: int = -1,
new_filename: Optional[str] = None,
) -> None:
"""Deal with result (write to STDOUT or to file)"""
if result is None:
return
if args.output_dir is None:
sys.stdout.write(result + "\n")
else:
destination_path, destination_dir = determine_output_path(
args, orig_filename, result, counter, new_filename
)
# check the directory status
if check_outputdir_status(destination_dir) is True:
with open(destination_path, mode="w", encoding="utf-8") as outputfile:
outputfile.write(result)
def generate_filelist(inputdir: str) -> Generator[str, None, None]:
"Walk the directory tree and output all file names."
for root, _, inputfiles in walk(inputdir):
for fname in inputfiles:
yield path.join(root, fname)
def file_processing(
filename: str, args: Any, counter: int = -1, options: Optional[Extractor] = None
) -> None:
"Aggregated functions to process a file in a list."
if not options:
options = args_to_extractor(args)
options.source = filename
with open(filename, "rb") as inputf:
htmlstring = inputf.read()
file_stat = stat(filename)
ref_timestamp = min(file_stat.st_ctime, file_stat.st_mtime)
options.date_params["max_date"] = datetime.fromtimestamp(ref_timestamp).strftime(
"%Y-%m-%d"
)
result = examine(htmlstring, args, options=options)
write_result(result, args, filename, counter, new_filename=None)
def process_result(
htmlstring: str, args: Any, counter: int, options: Optional[Extractor]
) -> int:
"Extract text and metadata from a download webpage and eventually write out the result."
# backup option
fileslug = archive_html(htmlstring, args, counter) if args.backup_dir else ""
# process
result = examine(htmlstring, args, options=options)
write_result(
result, args, orig_filename=fileslug, counter=counter, new_filename=fileslug
)
# increment written file counter
if counter >= 0 and result:
counter += 1
return counter
def download_queue_processing(
url_store: UrlStore, args: Any, counter: int, options: Extractor
) -> Tuple[List[str], int]:
"Implement a download queue consumer, single- or multi-threaded."
errors = []
sleep_time = options.config.getfloat("DEFAULT", "SLEEP_TIME")
while not url_store.done:
bufferlist, url_store = load_download_buffer(url_store, sleep_time)
# process downloads
for url, result in buffered_downloads(
bufferlist, args.parallel, options=options
):
# handle result
if result and isinstance(result, str):
options.url = url
counter = process_result(result, args, counter, options)
else:
LOGGER.warning("No result for URL: %s", url)
errors.append(url)
return errors, counter
def cli_discovery(args: Any) -> int:
"Group CLI functions dedicated to URL discovery."
url_store = load_input_dict(args)
input_urls = url_store.dump_urls()
if args.list:
url_store.reset()
options = args_to_extractor(args)
func = partial(
find_feed_urls if args.feed else sitemap_search,
target_lang=args.target_language,
external=options.config.getboolean("DEFAULT", "EXTERNAL_URLS"),
sleep_time=options.config.getfloat("DEFAULT", "SLEEP_TIME"),
)
lock = RLock()
# link discovery and storage
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = (executor.submit(func, url) for url in input_urls)
# process results from the parallel threads and add them
# to the compressed URL dictionary for further processing
for future in as_completed(futures):
if future.result() is not None:
url_store.add_urls(future.result())
# empty buffer in order to spare memory
if args.list and len(url_store.get_known_domains()) >= args.parallel:
with lock:
url_store.print_unvisited_urls()
url_store.reset()
reset_caches()
# process the (rest of the) links found
exit_code = url_processing_pipeline(args, url_store)
# activate site explorer
if args.explore:
# add to compressed dict and crawl the remaining websites
control_dict = build_exploration_dict(url_store, input_urls, args)
cli_crawler(args, url_store=control_dict, options=options)
return exit_code
def build_exploration_dict(
url_store: UrlStore, input_urls: List[str], args: Any
) -> UrlStore:
"Find domains for which nothing has been found and add info to the crawl dict."
input_domains = {extract_domain(u) for u in input_urls}
still_to_crawl = input_domains - {
extract_domain(u) for u in url_store.get_known_domains()
}
new_input_urls = [u for u in input_urls if extract_domain(u) in still_to_crawl]
return add_to_compressed_dict(
new_input_urls,
blacklist=args.blacklist,
url_filter=args.url_filter,
verbose=args.verbose,
)
def cli_crawler(
args: Any,
n: int = 30,
url_store: Optional[UrlStore] = None,
options: Optional[Extractor] = None,
) -> None:
"""Start a focused crawler which downloads a fixed number of URLs within a website
and prints the links found in the process."""
options = options or args_to_extractor(args)
sleep_time = options.config.getfloat("DEFAULT", "SLEEP_TIME")
param_dict = {}
# load input URLs
if url_store is None:
spider.URL_STORE.add_urls(load_input_urls(args))
else:
spider.URL_STORE = url_store
# load crawl data
for hostname in spider.URL_STORE.get_known_domains():
if spider.URL_STORE.urldict[hostname].tuples:
startpage = spider.URL_STORE.get_url(hostname, as_visited=False)
if startpage:
param_dict[hostname] = spider.init_crawl(
startpage, lang=args.target_language
)
# update info
# TODO: register changes?
# if base_url != hostname:
# ...
# iterate until the threshold is reached
while not spider.URL_STORE.done:
bufferlist, spider.URL_STORE = load_download_buffer(
spider.URL_STORE, sleep_time
)
for url, result in buffered_response_downloads(
bufferlist, args.parallel, options=options
):
if result and isinstance(result, Response):
spider.process_response(result, param_dict[get_base_url(url)])
# early exit if maximum count is reached
if any(c >= n for c in spider.URL_STORE.get_all_counts()):
break
print("\n".join(u for u in spider.URL_STORE.dump_urls()))
def probe_homepage(args: Any) -> None:
"Probe websites for extractable content and print the fitting ones."
input_urls = load_input_urls(args)
options = args_to_extractor(args)
for url, result in buffered_downloads(
input_urls, args.parallel, options=options
):
if result is not None:
result = html2txt(result)
if (
result
and len(result) > options.min_extracted_size # type: ignore[attr-defined]
and any(c.isalpha() for c in result)
):
if (
not LANGID_FLAG
or not args.target_language
or language_classifier(result, "") == args.target_language
):
print(url, flush=True)
def _define_exit_code(errors: List[str], total: int) -> int:
"""Compute exit code based on the number of errors:
0 if there are no errors, 126 if there are too many, 1 otherwise."""
ratio = len(errors) / total if total > 0 else 0
if ratio > 0.99:
return 126
if errors:
return 1
return 0
def url_processing_pipeline(args: Any, url_store: UrlStore) -> int:
"Aggregated functions to show a list and download and process an input list."
if args.list:
url_store.print_unvisited_urls() # and not write_result()
return False # and not sys.exit(0)
options = args_to_extractor(args)
url_count = url_store.total_url_number()
counter = 0 if url_count > MAX_FILES_PER_DIRECTORY else -1
# download strategy
errors, counter = download_queue_processing(url_store, args, counter, options)
LOGGER.debug("%s / %s URLs could not be found", len(errors), url_count)
if args.archived is True:
url_store = UrlStore()
url_store.add_urls(["https://web.archive.org/web/20/" + e for e in errors])
if len(url_store.find_known_urls("https://web.archive.org")) > 0:
archived_errors, _ = download_queue_processing(
url_store, args, counter, options
)
LOGGER.debug(
"%s archived URLs out of %s could not be found",
len(archived_errors),
len(errors),
)
# pass information along if URLs are missing
return _define_exit_code(archived_errors, url_store.total_url_number())
return _define_exit_code(errors, url_count)
def file_processing_pipeline(args: Any) -> None:
"Define batches for parallel file processing and perform the extraction."
filecounter = -1
options = args_to_extractor(args)
timeout = options.config.getint("DEFAULT", "EXTRACTION_TIMEOUT")
# max_tasks_per_child available in Python >= 3.11
with ProcessPoolExecutor(max_workers=args.parallel) as executor:
# chunk input: https://github.com/python/cpython/issues/74028
for filebatch in make_chunks(
generate_filelist(args.input_dir), MAX_FILES_PER_DIRECTORY
):
if filecounter < 0 and len(filebatch) >= MAX_FILES_PER_DIRECTORY:
filecounter = 0
worker = partial(
file_processing, args=args, counter=filecounter, options=options
)
executor.map(worker, filebatch, chunksize=10, timeout=timeout)
# update counter
if filecounter >= 0:
filecounter += len(filebatch)
def examine(
htmlstring: Optional[Any],
args: Any,
url: Optional[str] = None,
options: Optional[Extractor] = None,
) -> Optional[str]:
"Generic safeguards and triggers around extraction function."
result = None
if not options:
options = args_to_extractor(args, url)
# safety check
if htmlstring is None:
sys.stderr.write("ERROR: empty document\n")
elif not is_acceptable_length(len(htmlstring), options):
sys.stderr.write("ERROR: file size\n")
# proceed
else:
try:
result = extract(htmlstring, options=options)
# ugly but efficient
except Exception as err:
sys.stderr.write(f"ERROR: {str(err)}\n{traceback.format_exc()}\n")
return result