from __future__ import annotations import hashlib import importlib.util import json import os import subprocess import sys import tempfile import unittest from datetime import datetime, timezone from pathlib import Path from unittest import mock PROJECT_ROOT = Path(__file__).resolve().parents[3] PROJECT_DEV = Path(__file__).resolve().parents[1] TMP_ROOT = PROJECT_ROOT / "dev" / "tmp" MODULE_PATH = PROJECT_DEV / "bili_article_image_collector.py" SPEC = importlib.util.spec_from_file_location("bili_article_image_collector", MODULE_PATH) assert SPEC and SPEC.loader collector = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = collector SPEC.loader.exec_module(collector) HOST_PATH = PROJECT_DEV / "bili_article_image_native_host.py" HOST_SPEC = importlib.util.spec_from_file_location("bili_article_image_native_host", HOST_PATH) assert HOST_SPEC and HOST_SPEC.loader native_host = importlib.util.module_from_spec(HOST_SPEC) sys.modules[HOST_SPEC.name] = native_host HOST_SPEC.loader.exec_module(native_host) VALIDATOR_PATH = PROJECT_DEV / "bili_article_image_source_validator.py" VALIDATOR_SPEC = importlib.util.spec_from_file_location("bili_article_image_source_validator", VALIDATOR_PATH) assert VALIDATOR_SPEC and VALIDATOR_SPEC.loader source_validator = importlib.util.module_from_spec(VALIDATOR_SPEC) sys.modules[VALIDATOR_SPEC.name] = source_validator VALIDATOR_SPEC.loader.exec_module(source_validator) def canonical_json(value: object) -> bytes: return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") def accepted_snapshot_sha256(items: list[dict[str, object]]) -> str: canonical_items = [] for item in items: published = datetime.fromisoformat(str(item["published_at"])) canonical_items.append({ "body_complete": True, "body_text": str(item["body_text"]).replace("\r\n", "\n").replace("\r", "\n").rstrip("\n"), "image_count": len(item["images"]), "item_type": item["item_type"], "published_at_epoch_ms": int(published.timestamp() * 1000), "source_url": item["source_url"], "stable_id": item["stable_id"], "title": str(item["title"]).strip(), }) canonical_items.sort(key=lambda item: str(item["stable_id"])) payload = json.dumps({"items": canonical_items, "schema_version": 1}, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(payload).hexdigest().upper() class GenericArticleImageCollectorTests(unittest.TestCase): def setUp(self) -> None: TMP_ROOT.mkdir(parents=True, exist_ok=True) self.temp = tempfile.TemporaryDirectory(dir=TMP_ROOT) self.root = Path(self.temp.name) self.intake = self.root / "intake" self.intake.mkdir() def tearDown(self) -> None: self.temp.cleanup() def write_json(self, path: Path, value: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(canonical_json(value)) def config(self, uid: str, name: str, *, policy: str = "verify_or_append", output: Path | None = None) -> tuple[Path, Path]: output_root = output or (self.root / f"out-{uid}") path = self.root / f"config-{uid}.json" self.write_json( path, { "schema_version": 1, "creator": {"uid": uid, "name": name}, "page": { "dynamic_url": f"https://space.bilibili.com/{uid}/dynamic", "profile_url": f"https://space.bilibili.com/{uid}", }, "output": {"root": str(output_root), "intake_root": str(self.intake), "manifest_name": "manifest.jsonl"}, "selection": { "date_start": "2026-08-01T00:00:00+08:00", "date_end": "2026-08-31T23:59:59+08:00", "window_days": None, "timezone": "Asia/Shanghai", "include_types": ["article", "text", "image"], }, "readiness": {"deadline_seconds": 30, "observation_interval_ms": 100, "stable_observations": 3}, "rerun": {"policy": policy}, "verification": {"summary_path": None}, "limits": {"max_items": 20, "max_body_bytes": 1048576, "max_images_per_item": 8, "max_image_bytes": 1048576}, }, ) return path, output_root def image(self, name: str, extension: str) -> dict[str, object]: if extension == ".png": payload = b"\x89PNG\r\n\x1a\n" + b"p" * 24 elif extension == ".webp": payload = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"w" * 20 else: payload = b"\xff\xd8\xff" + b"j" * 29 path = self.intake / f"{name}{extension}" path.write_bytes(payload) return {"path": path.name, "bytes": len(payload), "sha256": hashlib.sha256(payload).hexdigest().upper(), "extension": extension} @staticmethod def observations(items: list[dict[str, object]], *states: str, ready_sha: str | None = None) -> list[dict[str, object]]: result = [] fingerprint = ready_sha or accepted_snapshot_sha256(items) for index, state in enumerate(states): result.append( { "elapsed_ms": index * 100 + 1, "state": state, "reason": state, "snapshot_sha256": fingerprint if state == "READY" else None, } ) return result def capture( self, uid: str, name: str, items: list[dict[str, object]], states: tuple[str, ...] = ("METADATA_NOT_READY", "READY", "READY", "READY"), *, ready_sha: str | None = None, ) -> Path: path = self.root / f"capture-{uid}-{len(list(self.root.glob('capture-*.json')))}.json" self.write_json( path, { "schema_version": 1, "creator_uid": uid, "creator_name": name, "dynamic_url": f"https://space.bilibili.com/{uid}/dynamic", "profile_url": f"https://space.bilibili.com/{uid}", "observations": self.observations(items, *states, ready_sha=ready_sha), "items": items, }, ) return path @staticmethod def item(stable_id: str, item_type: str, title: str, body: str, images: list[dict[str, object]] | None = None) -> dict[str, object]: return { "stable_id": stable_id, "item_type": item_type, "title": title, "source_url": f"https://www.bilibili.com/opus/{stable_id}", "published_at": "2026-08-12T10:00:00+08:00", "body_text": body, "body_complete": True, "images": images or [], } def test_two_configured_creators_full_body_images_and_safe_rerun(self) -> None: config_a, output_a = self.config("10001", "创作者甲") long_body = "正文段落\n" * 300 capture_a = self.capture( "10001", "创作者甲", [ self.item("opusA1", "article", "完整文章", long_body, [self.image("a1", ".png")]), self.item("opusA2", "text", "文字动态", "完整文字动态"), self.item("opusA3", "image", "图片动态", "图片说明", [self.image("a3", ".jpg")]), ], ) code, result = collector.run(["--config", str(config_a), "collect", "--capture", str(capture_a)]) self.assertEqual(0, code, result) self.assertEqual((3, 5), (result["new_items"], result["artifact_count"])) self.assertIn(long_body.encode("utf-8"), next(output_a.glob("*opusA1.txt")).read_bytes()) code, verified = collector.run(["--config", str(config_a), "verify"]) self.assertEqual(0, code, verified) self.assertEqual((3, 1, 2, 2), (verified["item_count"], verified["article_count"], verified["text_image_dynamic_count"], verified["original_image_count"])) code, rerun = collector.run(["--config", str(config_a), "collect", "--capture", str(capture_a)]) self.assertEqual(0, code, rerun) self.assertEqual(("NO_NEW_ITEMS", 0), (rerun["status"], rerun["mutation_count"])) config_b, output_b = self.config("20002", "Creator-B") capture_b = self.capture("20002", "Creator-B", [self.item("opusB1", "text", "Second creator", "Independent corpus")]) code, result_b = collector.run(["--config", str(config_b), "collect", "--capture", str(capture_b)]) self.assertEqual(0, code, result_b) self.assertEqual(1, len(list(output_b.glob("*.txt")))) self.assertNotEqual(output_a, output_b) def test_pending_intermission_requires_three_consecutive_ready_samples(self) -> None: config_path, _ = self.config("30003", "稳定性样例") item = self.item("stable1", "text", "稳定", "正文") valid = self.capture("30003", "稳定性样例", [item], ("OWNER_PENDING", "READY", "READY", "DIMENSIONS_PENDING", "READY", "READY", "READY")) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(valid)]) self.assertEqual(0, code, result) self.assertEqual(7, result["readiness_attempts"]) invalid = self.capture("30003", "稳定性样例", [item], ("READY", "READY", "METADATA_NOT_READY", "READY", "READY")) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(invalid)]) self.assertEqual((3, "E_READINESS_TIMEOUT", 0), (code, result["error_code"], result["mutation_count"])) def test_ready_tail_binds_exact_canonical_items_and_rejects_trailing_evidence(self) -> None: config_path, output_root = self.config("31003", "快照绑定样例") shared_vector = { "stable_id": "snap_1", "item_type": "article", "title": "Shared", "source_url": "https://www.bilibili.com/opus/snap_1", "published_at": datetime.fromisoformat("2026-08-12T10:00:00+08:00"), "body": "正文\nline\n".encode("utf-8"), "images": [{}], } self.assertEqual("4F0616496B08F8537A1C9F17B48D53BC2F93A26EB15AB5977C6E891ABA411454", collector._accepted_snapshot_sha256([shared_vector])) first = self.item("snapshot1", "article", "快照一", "BODY-A") second = self.item("snapshot2", "text", "快照二", "BODY-B") unrelated = hashlib.sha256(b"unrelated").hexdigest().upper() digest_mismatch = self.capture("31003", "快照绑定样例", [first], ready_sha=unrelated) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(digest_mismatch)]) self.assertEqual((3, "E_READINESS_DIGEST", 0), (code, result["error_code"], result["mutation_count"])) original_digest = accepted_snapshot_sha256([first]) changed_body = dict(first) changed_body["body_text"] = "BODY-B-NOT-OBSERVED" body_drift = self.capture("31003", "快照绑定样例", [changed_body], ready_sha=original_digest) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(body_drift)]) self.assertEqual((3, "E_READINESS_DIGEST", 0), (code, result["error_code"], result["mutation_count"])) multi_not_shared = self.capture("31003", "快照绑定样例", [first, second], ready_sha=original_digest) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(multi_not_shared)]) self.assertEqual((3, "E_READINESS_DIGEST", 0), (code, result["error_code"], result["mutation_count"])) trailing_access = self.capture("31003", "快照绑定样例", [first], ("READY", "READY", "READY", "ACCESS_BLOCKED")) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(trailing_access)]) self.assertEqual((3, "E_ACCESS_CONTROL", 0), (code, result["error_code"], result["mutation_count"])) trailing_pending = self.capture("31003", "快照绑定样例", [first], ("READY", "READY", "READY", "METADATA_NOT_READY")) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(trailing_pending)]) self.assertEqual((3, "E_READINESS_TIMEOUT", 0), (code, result["error_code"], result["mutation_count"])) self.assertFalse(output_root.exists()) def test_post_validation_image_drift_fails_before_pending_or_formal_mutation(self) -> None: config_path, output_root = self.config("32003", "图片冻结样例") image = self.image("drift", ".png") source_path = self.intake / str(image["path"]) capture_path = self.capture("32003", "图片冻结样例", [self.item("drift1", "image", "图片漂移", "完整正文", [image])]) original_validate = collector.validate_capture def validate_then_drift(config: collector.CollectorConfig, path: Path) -> dict[str, object]: value = original_validate(config, path) source_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"q" * 24) return value with mock.patch.object(collector, "validate_capture", side_effect=validate_then_drift): code, result = collector.run(["--config", str(config_path), "collect", "--capture", str(capture_path)]) self.assertEqual((3, "E_ARTIFACT_DRIFT", 0), (code, result["error_code"], result["mutation_count"])) self.assertFalse(output_root.exists()) self.assertFalse((output_root / "manifest.jsonl").exists()) self.assertEqual([], list(output_root.glob(".bili-article-image.pending.*.json"))) self.assertEqual([], list(output_root.glob("*.txt"))) self.assertEqual([], list(output_root.glob("*.png"))) config_frozen, output_frozen = self.config("32004", "冻结载荷样例") frozen_image = self.image("frozen", ".png") frozen_source = self.intake / str(frozen_image["path"]) expected_payload = frozen_source.read_bytes() frozen_capture = self.capture("32004", "冻结载荷样例", [self.item("frozen1", "image", "冻结载荷", "完整正文", [frozen_image])]) original_create_new = collector._create_new mutated_after_freeze = False def create_then_mutate_intake(path: Path, payload: bytes) -> None: nonlocal mutated_after_freeze original_create_new(path, payload) if collector.OWNED_PENDING.fullmatch(path.name) and not mutated_after_freeze: frozen_source.write_bytes(b"\x89PNG\r\n\x1a\n" + b"r" * 24) mutated_after_freeze = True with mock.patch.object(collector, "_create_new", side_effect=create_then_mutate_intake): code, result = collector.run(["--config", str(config_frozen), "collect", "--capture", str(frozen_capture)]) self.assertEqual((0, "CONTENT_SAVED", True), (code, result["status"], mutated_after_freeze)) published = next(output_frozen.glob("*.png")) self.assertEqual(expected_payload, published.read_bytes()) manifest_row = json.loads((output_frozen / "manifest.jsonl").read_text(encoding="utf-8")) self.assertEqual( (len(expected_payload), hashlib.sha256(expected_payload).hexdigest().upper()), (manifest_row["images"][0]["bytes"], manifest_row["images"][0]["sha256"]), ) def test_identity_access_secret_partial_and_path_escape_fail_closed(self) -> None: config_path, output_root = self.config("40004", "安全样例") wrong = self.capture("40005", "安全样例", [self.item("bad1", "text", "bad", "body")]) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(wrong)]) self.assertEqual((3, "E_CREATOR_IDENTITY"), (code, result["error_code"])) blocked = self.capture("40004", "安全样例", [self.item("bad2", "text", "bad", "body")], ("ACCESS_BLOCKED",)) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(blocked)]) self.assertEqual((3, "E_ACCESS_CONTROL"), (code, result["error_code"])) partial = self.capture("40004", "安全样例", [self.item("bad3", "text", "bad", " ")]) code, result = collector.run(["--config", str(config_path), "validate-capture", "--capture", str(partial)]) self.assertEqual((3, "E_CONTENT_INCOMPLETE"), (code, result["error_code"])) config_value = json.loads(config_path.read_text(encoding="utf-8")) config_value["cookie"] = "synthetic-marker" self.write_json(config_path, config_value) code, result = collector.run(["--config", str(config_path), "verify"]) self.assertEqual((3, "E_SECRET_FIELD"), (code, result["error_code"])) self.assertFalse(output_root.exists()) self.config("40004", "安全样例", output=PROJECT_ROOT.parent / "escape") code, result = collector.run(["--config", str(self.root / "config-40004.json"), "verify"]) self.assertEqual((3, "E_PATH_ESCAPE"), (code, result["error_code"])) config_path, _ = self.config("40004", "安全样例") outside_terminal = PROJECT_ROOT.parent / f"bili-article-image-outside-terminal-{os.getpid()}.json" self.assertFalse(outside_terminal.exists()) code, result = collector.run(["--config", str(config_path), "verify", "--terminal", str(outside_terminal)]) self.assertEqual((3, "E_PATH_ESCAPE", 0), (code, result["error_code"], result["mutation_count"])) self.assertFalse(outside_terminal.exists()) def test_collision_precommit_recovery_and_reparse_are_mutation_zero(self) -> None: config_path, output_root = self.config("50005", "恢复样例") capture_path = self.capture("50005", "恢复样例", [self.item("recover1", "article", "恢复", "完整正文")]) config = collector.load_config(config_path) empty = hashlib.sha256(b"").hexdigest().upper() with mock.patch.object(collector, "_manifest_snapshot", side_effect=[(0, empty), (1, "A" * 64), (0, empty)]): with self.assertRaises(collector.CollectorError) as raised: collector.collect(config, capture_path, None) self.assertEqual("E_PRECOMMIT_DRIFT", raised.exception.code) self.assertEqual([], list(output_root.glob("*.txt"))) self.assertFalse((output_root / "manifest.jsonl").exists()) self.assertEqual([], list(output_root.glob(".bili-article-image.pending.*.json"))) capture = collector.validate_capture(config, capture_path) item = capture["items"][0] local = item["published_at"].astimezone(config.tz) stem = f"{local:%Y%m%d-%H%M%S}_{item['item_type']}_{collector._safe_component(item['title'], max_length=48)}_{item['stable_id']}" output_root.mkdir(parents=True, exist_ok=True) (output_root / f"{stem}.txt").write_bytes(b"collision") code, result = collector.run(["--config", str(config_path), "collect", "--capture", str(capture_path)]) self.assertEqual((3, "E_TARGET_EXISTS", 0), (code, result["error_code"], result["mutation_count"])) probe = output_root / "reparse-probe" probe.mkdir() with mock.patch.object(collector, "_is_reparse", side_effect=lambda path: path == probe): with self.assertRaises(collector.CollectorError) as reparse: collector._safe_existing_chain(probe / "child") self.assertEqual("E_PATH_REPARSE", reparse.exception.code) pending = output_root / f".bili-article-image.pending.{'a' * 32}.json" pending.write_bytes(canonical_json({"schema_version": 1, "status": "PUBLISH_PENDING"})) before = pending.read_bytes() code, result = collector.run(["--config", str(config_path), "verify"]) self.assertEqual((3, "E_RECOVERY_REQUIRED", 0), (code, result["error_code"], result["mutation_count"])) self.assertEqual(before, pending.read_bytes()) def test_native_host_strict_boundary_and_real_corpus_readback(self) -> None: real_config = PROJECT_ROOT / "dev" / "tmp" / "bili-article-image-generic-real-validation-config-20260825.json" code, result = native_host.run_request( {"schema_version": 1, "action": "verify", "config_path": str(real_config), "capture_path": None, "terminal_path": None} ) self.assertEqual(0, code, result) self.assertEqual((85, 22, 63, 16), (result["item_count"], result["article_count"], result["text_image_dynamic_count"], result["original_image_count"])) self.assertEqual("4CF0BB9936431C24278A1209C0E4CB1BD3645EF8E14BBC4B32F9B22629211EE3", result["manifest_sha256"]) code, blocked = native_host.run_request( {"schema_version": 1, "action": "verify", "config_path": str(real_config), "capture_path": None, "terminal_path": None, "session_token": "synthetic"} ) self.assertEqual((3, "E_HOST_SCHEMA", 0), (code, blocked["error_code"], blocked["mutation_count"])) def test_runtime_sources_have_no_current_creator_constant_or_secret_api(self) -> None: runtime_paths = [ MODULE_PATH, PROJECT_DEV / "bili_article_image_capture.js", HOST_PATH, PROJECT_DEV / "bili_dynamic_collector.py", PROJECT_DEV / "bili_dynamic_refresh_extension" / "service_worker.js", PROJECT_DEV / "bili_dynamic_refresh_extension" / "page_extract.js", PROJECT_DEV / "bili_dynamic_refresh_native_host" / "constants.py", PROJECT_DEV / "bili_dynamic_refresh_native_host" / "protocol.py", PROJECT_DEV / "bili_article_image_collector.example.json", ] text = "\n".join(path.read_text(encoding="utf-8") for path in runtime_paths) for forbidden in ["1420210197", "青枫浦上Q", "document.cookie", "localStorage", "Profile", "--cookies", "Cookie:"]: self.assertNotIn(forbidden, text) def test_browser_capture_two_creator_and_readiness_contract(self) -> None: completed = subprocess.run( ["node", str(Path(__file__).with_name("test_bili_article_image_capture.mjs")), str(PROJECT_DEV / "bili_article_image_capture.js")], check=True, capture_output=True, text=True, timeout=20, ) result = json.loads(completed.stdout) self.assertEqual({"status": "PASS", "creators": 2, "readiness_observations": 7, "network_requests": 0}, result) def test_exact_source_manifest(self) -> None: result = source_validator.validate() self.assertEqual(("SOURCE_VALID", 28, 0), (result["status"], result["file_count"], result["mutation_count"])) if __name__ == "__main__": unittest.main()