fast parser

This commit is contained in:
qananasikq
2026-04-24 12:39:05 +03:00
parent 3a3222c7dc
commit fc1bea562a
19 changed files with 2482 additions and 92 deletions

117
tests/test_fast_client.py Normal file
View File

@@ -0,0 +1,117 @@
from __future__ import annotations
import json
from iaai_scraper.browser.fast_client import (
DETAIL_MARKER,
LISTING_MARKER,
build_resizer_images_from_keys,
is_challenge_response,
parse_listing_page,
parse_product_details_vm,
)
from iaai_scraper.parsing.fast_mapper import FastCarMapper
def test_parse_listing_page_extracts_hidden_payloads() -> None:
gbp = {"CurrentPage": 1, "PageSize": 100}
vehicles = [
{
"Id": "45078011~US",
"Tenant": "US",
"InventoryStatus": "RS",
"Currency": "USD",
"PreBidIndicator": True,
}
]
html = (
f'<input id="GBPSearchQuery" value="{json.dumps(gbp).replace(chr(34), "&quot;")}" />'
f'<input id="VehicleDetails" value="{json.dumps(vehicles).replace(chr(34), "&quot;")}" />'
'<input id="ResultCount" value="1" />'
'<input id="PageSize" value="100" />'
'<input id="CurrentPage" value="1" />'
)
parsed = parse_listing_page(html)
assert parsed.result_count == 1
assert parsed.page_size == 100
assert parsed.current_page == 1
assert parsed.vehicles[0].inventory_id == "45078011~US"
assert parsed.vehicles[0].inventory_status == "RS"
def test_parse_product_details_vm_and_map_record() -> None:
payload = {
"inventoryView": {
"attributes": {
"Id": "45078011~US",
"Year": "2002",
"Make": "ACURA",
"Model": "RSX",
"Series": "BASE",
"Currency": "USD",
"ODOValue": "219187",
"ExteriorColor": "GRAY",
"DriveLineTypeDesc": "FWD",
"Transmission": "Automatic",
"BodyStyleName": "COUPE",
"EngineSize": "2.0L I-4",
"VehicleGrade": "50",
"PrimaryDamageDesc": "NORMAL WEAR & TEAR",
},
"imageDimensions": {
"keys": {
"$values": [
{"k": "45078011~US~I1", "w": 1600, "h": 1200, "i": 0},
]
}
},
},
"auctionInformation": {
"prebidInformation": {"decimalHighBidAmount": "600", "highBidAmount": "$600"},
"biddingInformation": {"buyNowPrice": "$0"},
},
}
html = f'<script id="ProductDetailsVM" type="application/json">{json.dumps(payload)}</script>'
parsed = parse_product_details_vm(html)
record = FastCarMapper().map_payload_to_record(
detail_payload=parsed,
vehicle_url="https://www.iaai.com/VehicleDetail/45078011~US",
)
assert record.origin_id == "iaai:45078011~US"
assert record.brand == "ACURA"
assert record.model == "RSX BASE"
assert record.year == 2002
assert record.price == 600
assert record.drive == "FWD"
assert record.gearbox == "AT"
assert record.body_type == "COUPE"
assert record.engine_volume == 2000
assert record.is_damaged is False
assert len(record.images) == 1
def test_build_resizer_images_from_keys_deduplicates_and_orders() -> None:
keys = [
{"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
{"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
{"k": "abc~2", "w": 1800, "h": 1200, "i": 1},
]
images = build_resizer_images_from_keys(keys)
assert len(images) == 2
assert images[0]["order_index"] == 1
assert "imageKeys=abc~2" in str(images[0]["fullres_image"])
def test_is_challenge_response_marker_rules() -> None:
assert is_challenge_response(status_code=403, body_text="", expected_marker=None) is True
assert is_challenge_response(status_code=200, body_text="<html>blocked</html>", expected_marker=LISTING_MARKER) is True
assert is_challenge_response(
status_code=200,
body_text=f'<html>{DETAIL_MARKER}<script src="/_Incapsula_Resource"></script></html>',
expected_marker=DETAIL_MARKER,
) is False

141
tests/test_fast_sync.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from dataclasses import dataclass
from iaai_scraper.browser.fast_client import FastListingVehicle
from iaai_scraper.core.config import Settings
from iaai_scraper.core.runtime_config import RuntimeConfig, RuntimeFiltersConfig
from iaai_scraper.fast_sync import FastSyncEngine
from iaai_scraper.parsing.fast_mapper import FastCarMapper
def _listing_vehicle(inventory_id: str, *, status: str = "RS") -> FastListingVehicle:
return FastListingVehicle(
inventory_id=inventory_id,
tenant="US",
auction_id="1_1",
auction_date="2026-04-08T08:30:00+00:00",
inventory_status=status,
currency="USD",
timed_auction_closed=False,
timed_auction_indicator=False,
prebid_indicator=True,
buynow_indicator=False,
)
def _detail_payload(inventory_id: str, *, make: str = "ACURA", model: str = "RSX", bid: int = 500) -> dict:
return {
"inventoryView": {
"attributes": {
"Id": inventory_id,
"Year": "2002",
"Make": make,
"Model": model,
"Series": "BASE",
"Currency": "USD",
"ODOValue": "219187",
"ExteriorColor": "GRAY",
"DriveLineTypeDesc": "FWD",
"Transmission": "Automatic",
"BodyStyleName": "COUPE",
"EngineSize": "2.0L I-4",
"VehicleGrade": "50",
"PrimaryDamageDesc": "NORMAL WEAR & TEAR",
},
"imageDimensions": {
"keys": {"$values": [{"k": f"{inventory_id}~I1", "w": 1600, "h": 1200, "i": 0}]}
},
},
"auctionInformation": {
"prebidInformation": {"decimalHighBidAmount": str(bid), "highBidAmount": f"${bid}"},
"biddingInformation": {"buyNowPrice": "$0"},
},
}
class FakeFastClient:
def __init__(self, listing: list[FastListingVehicle], details: dict[str, dict]) -> None:
self.listing = listing
self.details = details
self.detail_calls = 0
self.persist_calls = 0
def iter_listing_vehicles(self, *, listing_start_url=None, make=None, max_pages=None): # noqa: ANN001, ANN202
del listing_start_url, make, max_pages
return iter(self.listing)
def fetch_vehicle_detail_payload(self, inventory_id: str) -> dict:
self.detail_calls += 1
return self.details[inventory_id]
def persist_session_state(self) -> None:
self.persist_calls += 1
@dataclass
class FakePersistence:
inserted: int = 0
images: int = 0
def get_existing_urls_and_ids(self, origin_urls, origin_ids): # noqa: ANN001, ANN202
del origin_urls, origin_ids
return set(), set()
def upsert_cars_batch(self, records): # noqa: ANN001, ANN202
self.inserted += len(records)
self.images += sum(len(record.images) for record in records)
return {"inserted": len(records), "updated": 0, "images_upserted": sum(len(record.images) for record in records)}
def mark_sold_not_in_listing_by_urls(self, active_origin_urls, lane="iaai"): # noqa: ANN001, ANN202
del active_origin_urls, lane
return 0
def test_fast_sync_engine_collects_details_and_bulk_upserts() -> None:
listing = [_listing_vehicle("45078011~US"), _listing_vehicle("45268167~US")]
details = {
"45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500),
"45268167~US": _detail_payload("45268167~US", make="BMW", model="X5", bid=900),
}
client = FakeFastClient(listing, details)
persistence = FakePersistence()
engine = FastSyncEngine(
client=client, # type: ignore[arg-type]
mapper=FastCarMapper(),
persistence=persistence, # type: ignore[arg-type]
batch_size=10,
fetch_concurrency=2,
)
result = engine.sync_listing(runtime_config=RuntimeConfig(), only_new=False)
assert result["status"] == "success"
assert result["cars_upserted"] == 2
assert result["images_upserted"] == 2
assert result["listing"]["mode"] == "fast_hidden_payload"
assert client.detail_calls == 2
assert client.persist_calls == 1
def test_fast_sync_engine_applies_runtime_filters_before_db() -> None:
listing = [_listing_vehicle("45078011~US"), _listing_vehicle("45268167~US")]
details = {
"45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500),
"45268167~US": _detail_payload("45268167~US", make="BMW", model="X5", bid=900),
}
runtime = RuntimeConfig(filters=RuntimeFiltersConfig.from_dict({"brands": ["BMW"]}))
persistence = FakePersistence()
engine = FastSyncEngine(
client=FakeFastClient(listing, details), # type: ignore[arg-type]
mapper=FastCarMapper(),
persistence=persistence, # type: ignore[arg-type]
batch_size=10,
fetch_concurrency=2,
)
result = engine.sync_listing(runtime_config=runtime, only_new=False)
assert result["cars_upserted"] == 1
assert result["cars_filtered"] == 1
assert persistence.inserted == 1

View File

@@ -3,7 +3,13 @@ from __future__ import annotations
import unittest
from iaai_scraper.core.utils import deep_find_key
from iaai_scraper.core.config import parse_listing_segments, IAAI_DEFAULT_MAKES, _LARGE_MAKES, _YEAR_SPLITS
from iaai_scraper.core.config import (
IAAI_DEFAULT_MAKES,
_LARGE_MAKES,
_YEAR_SPLITS,
build_listing_segments_for_makes,
parse_listing_segments,
)
class TestDeepFindKey(unittest.TestCase):
@@ -70,6 +76,16 @@ class TestParseListingSegments(unittest.TestCase):
self.assertEqual(segs[0]["year_min"], 2020)
self.assertEqual(segs[0]["year_max"], 2025)
def test_build_listing_segments_for_makes(self) -> None:
segs = build_listing_segments_for_makes(["toyota", "Lexus", "TOYOTA", " "])
toyota = [s for s in segs if s["make"] == "TOYOTA"]
lexus = [s for s in segs if s["make"] == "LEXUS"]
self.assertEqual(len(toyota), len(_YEAR_SPLITS))
self.assertEqual(len(lexus), 1)
self.assertIsNone(lexus[0]["year_min"])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,15 +1,34 @@
from __future__ import annotations
import json
import os
from dataclasses import replace
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from iaai_scraper.worker import tasks
from iaai_scraper.core.config import settings as base_settings
from iaai_scraper.core.config import Settings, settings as base_settings
class TestWorkerTaskLockHelpers(unittest.TestCase):
def test_build_listing_segments_from_runtime_config_brands(self) -> None:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as fh:
json.dump({"filters": {"brands": ["Toyota", "Lexus", "Toyota"]}}, fh)
runtime_config_file = fh.name
settings = Settings(runtime_config_file=runtime_config_file)
settings.listing.listing_segments_json = "runtime"
segs = tasks._build_listing_segments(settings)
toyota = [s for s in segs if s["make"] == "TOYOTA"]
lexus = [s for s in segs if s["make"] == "LEXUS"]
self.assertEqual(len(toyota), 3)
self.assertEqual(len(lexus), 1)
self.assertIsNone(lexus[0]["year_min"])
os.unlink(runtime_config_file)
def test_lock_acquire_refresh_release(self) -> None:
redis_client = MagicMock()