from __future__ import annotations import argparse import hashlib import json import os import re import time import urllib.parse import urllib.error from collections import deque from html.parser import HTMLParser from pathlib import Path from typing import Any from fetch_1c_its_docs import ( LinkParser, charset_from_content_type, fetch_url, is_content_src_url, normalize_url, page_record, url_priority, ) from one_c_its_platform import materialize_doc_url, merge_platform_metadata, parse_doc_coordinate ROOT = Path(__file__).resolve().parents[1] DEFAULT_RAW_DIR = ROOT / "plugins" / "1c" / "rag" / "official-docs" / "raw" DEFAULT_MANIFEST = DEFAULT_RAW_DIR / "manifest.json" BOOK_SOURCE_TYPES = { "dev": "official_1c_its_developer_guide", "adm": "official_1c_its_admin_guide", "cs": "official_1c_its_client_server_admin_guide", "usr": "official_1c_its_user_guide", "utx": "official_1c_its_taxi_user_guide", } class AccessMarkerParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.login_links = 0 self.paywall_markers = 0 self.data_access_false = 0 self.iframe_srcs: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = {name.lower(): value or "" for name, value in attrs} href = values.get("href", "") class_name = values.get("class", "") if "/user/auth" in href: self.login_links += 1 if "paywall" in class_name: self.paywall_markers += 1 if values.get("data-access") == "false": self.data_access_false += 1 if tag.lower() == "iframe" and values.get("src"): self.iframe_srcs.append(values["src"]) def load_json(path: Path) -> dict[str, Any]: if not path.exists(): return {} data = json.loads(path.read_text(encoding="utf-8-sig")) return data if isinstance(data, dict) else {} def write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def read_cookie(cookie_file: Path | None) -> str: if cookie_file: return cookie_file.read_text(encoding="utf-8").strip() return os.environ.get("ONEC_ITS_COOKIE", "").strip() def access_markers_from_html(url: str, body: bytes, content_type: str) -> dict[str, Any]: text = body.decode(charset_from_content_type(content_type), errors="replace") parser = AccessMarkerParser() parser.feed(text) iframe_src = "" for src in parser.iframe_srcs: if "/db/content/" in src and "/src/" in src: iframe_src = urllib.parse.urljoin(url, src) break findings = [] if parser.login_links: findings.append("login_links_present") if parser.paywall_markers: findings.append("paywall_marker_present") if parser.data_access_false: findings.append("data_access_false") return { "access_blocked": bool(findings), "access_findings": findings, "login_links": parser.login_links, "paywall_markers": parser.paywall_markers, "data_access_false": parser.data_access_false, "iframe_src": iframe_src, } def dedupe_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]: seen = set() result = [] for error in errors: key = (str(error.get("source_id") or ""), str(error.get("url") or ""), str(error.get("error") or "")) if key in seen: continue seen.add(key) result.append(error) return result def source_from_url(url: str, title: str | None = None) -> dict[str, Any]: coord = parse_doc_coordinate(url) doc_id = coord.get("platform_doc_id") or "its_doc" book = coord.get("doc_book") or "" source_type = BOOK_SOURCE_TYPES.get(book, "official_1c_its_platform_doc") platform_version = coord.get("platform_version") source_id_parts = [doc_id] if book: source_id_parts.append(book) if coord.get("doc_bookmark") or coord.get("doc_content_id"): source_id_parts.append(str(coord.get("doc_bookmark") or coord.get("doc_content_id"))) source_id = "_target_" + "_".join(re.sub(r"[^A-Za-zА-Яа-яЁё0-9_.-]+", "_", part) for part in source_id_parts) source = { "id": source_id, "title": title or f"1C:ITS target {coord.get('doc_coordinate') or url}", "source_type": source_type, "platform_family": "1C:Enterprise 8" if doc_id else "", "platform_version": platform_version, "url": url, } return merge_platform_metadata(source, url) def same_doc_space(url: str, seed_url: str) -> bool: parsed = urllib.parse.urlparse(url) seed = urllib.parse.urlparse(seed_url) if parsed.netloc.lower() != seed.netloc.lower(): return False seed_doc_id = parse_doc_coordinate(seed_url).get("platform_doc_id") if seed_doc_id and parsed.path.startswith(f"/db/content/{seed_doc_id}/src/"): return True seed_root = "/".join(seed.path.strip("/").split("/")[:2]) path_root = "/".join(parsed.path.strip("/").split("/")[:2]) return bool(seed_root) and path_root == seed_root def should_follow(url: str, seed_url: str) -> bool: if not same_doc_space(url, seed_url): return False path = urllib.parse.urlparse(url).path return ( is_content_src_url(url) or re.search(r"/db/[^/]+/content/\d+/hdoc(?:/\d+)?$", path) or re.search(r"/db/[^/]+/content/\d+/\d+$", path) or "/bookmark/" in path ) def target_link_url(base_url: str, href: str) -> str | None: if href.startswith("#TI"): coord = parse_doc_coordinate(base_url) doc_id = coord.get("platform_doc_id") book = coord.get("doc_book") if doc_id and book: bookmark = href.lstrip("#") parsed = urllib.parse.urlparse(base_url) path = f"/db/{doc_id}/bookmark/{book}/{bookmark}" return urllib.parse.urlunparse(parsed._replace(path=path, query="", fragment="")) return normalize_url(base_url, href) def hdoc_variant(url: str) -> str | None: parsed = urllib.parse.urlparse(url) replaced = re.sub(r"(/db/[^/]+/content/\d+)/1$", r"\1/hdoc", parsed.path) if replaced == parsed.path: return None return urllib.parse.urlunparse(parsed._replace(path=replaced, query="", fragment="")) def parse_links(record: dict[str, Any], raw_dir: Path) -> list[str]: path = raw_dir / str(record.get("file") or "") if not path.exists(): return [] raw = path.read_bytes() text = raw.decode(charset_from_content_type(str(record.get("content_type") or "")), errors="replace") parser = LinkParser() parser.feed(text) return parser.links def enrich_record_access_markers(record: dict[str, Any], raw_dir: Path) -> dict[str, Any]: if record.get("access_findings") is not None: return record path = raw_dir / str(record.get("file") or "") if not path.exists(): return record try: markers = access_markers_from_html(str(record.get("url") or ""), path.read_bytes(), str(record.get("content_type") or "")) except OSError: return record updated = dict(record) updated.update(markers) return updated def fetch_target( *, url: str, output_dir: Path, manifest_path: Path, cookie: str, max_pages: int, max_depth: int, timeout: int, user_agent: str, ) -> dict[str, Any]: output_dir.mkdir(parents=True, exist_ok=True) materialized_url = materialize_doc_url(url) source = source_from_url(url) existing_manifest = load_json(manifest_path) pages_by_url = { str(record.get("url")): record for record in existing_manifest.get("pages") or [] if record.get("url") } errors = list(existing_manifest.get("errors") or []) target_errors = [] fetched = [] reused = [] seen: set[str] = set() queue: deque[tuple[str, int, str | None]] = deque([(materialized_url, 0, None)]) while queue and len(fetched) + len(reused) < max_pages: current_url, depth, title_hint = queue.popleft() if current_url in seen: continue seen.add(current_url) record = pages_by_url.get(current_url) if record: record = enrich_record_access_markers(record, output_dir) pages_by_url[current_url] = record reused.append(current_url) else: try: body, headers, status = fetch_url(current_url, cookie=cookie, timeout=timeout, user_agent=user_agent) record = page_record( source=source, url=current_url, depth=depth, body=body, headers=headers, status=status, output_dir=output_dir, title_hint=title_hint, ) record.update(access_markers_from_html(current_url, body, str(headers.get("Content-Type") or ""))) pages_by_url[current_url] = {key: value for key, value in record.items() if key != "links"} fetched.append(current_url) except (urllib.error.URLError, TimeoutError, OSError, UnicodeDecodeError) as exc: error = {"source_id": source.get("id"), "url": current_url, "depth": depth, "error": str(exc)} errors.append(error) target_errors.append(error) continue if depth >= max_depth: continue links = record.get("links") if "links" in record else parse_links(record, output_dir) candidates = [] current_hdoc = hdoc_variant(current_url) if current_hdoc and current_hdoc not in seen and should_follow(current_hdoc, materialized_url): candidates.append(current_hdoc) for href in links or []: next_url = target_link_url(current_url, href) if not next_url or next_url in seen or not should_follow(next_url, materialized_url): continue candidates.append(next_url) next_hdoc = hdoc_variant(next_url) if next_hdoc and next_hdoc not in seen and should_follow(next_hdoc, materialized_url): candidates.append(next_hdoc) for next_url in sorted(set(candidates), key=url_priority): queue.append((next_url, depth + 1, str(record.get("title") or title_hint or ""))) errors = dedupe_errors(errors) target_errors = dedupe_errors(target_errors) pages = sorted(pages_by_url.values(), key=lambda item: str(item.get("url") or "")) access_blocked_pages = [ { "url": page.get("url"), "findings": page.get("access_findings") or [], "iframe_src": page.get("iframe_src") or "", } for page in pages if page.get("access_blocked") ] result = { "schema": existing_manifest.get("schema") or "onec_its_raw_fetch_manifest.v1", "access": existing_manifest.get("access") or "licensed_private", "output_dir": str(output_dir), "page_count": len(pages), "error_count": len(errors), "resume": existing_manifest.get("resume") or {"enabled": True}, "stop": { "status": "complete", "reason": "target_fetch_complete", "max_pages": max_pages, "target_url": url, "materialized_url": materialized_url, "access_blocked_pages": len(access_blocked_pages), }, "records_by_source": existing_manifest.get("records_by_source") or {}, "errors_by_source": existing_manifest.get("errors_by_source") or {}, "pages": pages, "errors": errors, } write_json(manifest_path, result) progress_path = manifest_path.parent / "progress.json" write_json( progress_path, { "schema": "onec_its_target_fetch_progress.v1", "status": "complete", "updated_at_unix": int(time.time()), "target_url": url, "materialized_url": materialized_url, "source": source, "fetched": fetched, "reused": reused, "errors": target_errors, "access_blocked_pages": access_blocked_pages[-20:], "page_count": len(pages), "error_count": len(errors), }, ) return { "target_url": url, "materialized_url": materialized_url, "source": source, "fetched_count": len(fetched), "reused_count": len(reused), "error_count": len(target_errors), "access_blocked_count": len(access_blocked_pages), "access_blocked_pages": access_blocked_pages[-20:], "fetched": fetched, "reused": reused, "errors": target_errors, "manifest": str(manifest_path), "progress": str(progress_path), } def main() -> int: parser = argparse.ArgumentParser(description="Fetch a targeted 1C:ITS documentation page and merge it into the raw manifest.") parser.add_argument("url") parser.add_argument("--output-dir", type=Path, default=DEFAULT_RAW_DIR) parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) parser.add_argument("--cookie-file", type=Path) parser.add_argument("--max-pages", type=int, default=12) parser.add_argument("--max-depth", type=int, default=2) parser.add_argument("--timeout", type=int, default=30) parser.add_argument("--user-agent", default="Codex 1C ITS target fetch") parser.add_argument("--print", action="store_true", dest="print_report") args = parser.parse_args() result = fetch_target( url=args.url, output_dir=args.output_dir, manifest_path=args.manifest, cookie=read_cookie(args.cookie_file), max_pages=max(1, args.max_pages), max_depth=max(0, args.max_depth), timeout=args.timeout, user_agent=args.user_agent, ) print(json.dumps(result if args.print_report else {key: result[key] for key in ("target_url", "materialized_url", "fetched_count", "reused_count", "error_count")}, ensure_ascii=False, indent=2)) return 0 if result["error_count"] == 0 or result["fetched_count"] or result["reused_count"] else 1 if __name__ == "__main__": raise SystemExit(main())