2146 lines
88 KiB
Python
2146 lines
88 KiB
Python
from __future__ import annotations
|
||
|
||
# Segment planning and overflow splitting live here to keep tasks.py focused
|
||
# on Celery orchestration. The module intentionally reuses task-layer
|
||
# helpers and globals that are synchronized from tasks.py before calls.
|
||
from .tasks import * # noqa: F401,F403
|
||
|
||
|
||
def _mobilede_price_ranges() -> list[tuple[int, int | None]]:
|
||
raw_ranges = os.getenv("MOBILEDE_PRICE_RANGES", "").strip()
|
||
if raw_ranges:
|
||
parsed: list[tuple[int, int | None]] = []
|
||
for raw_item in raw_ranges.split(","):
|
||
item = raw_item.strip()
|
||
if not item:
|
||
continue
|
||
left, _, right = item.partition(":")
|
||
try:
|
||
min_value = int(left.strip()) if left.strip() else 1
|
||
max_value = int(right.strip()) if right.strip() else None
|
||
parsed.append((min_value, max_value))
|
||
except ValueError:
|
||
logger.warning("Invalid MOBILEDE_PRICE_RANGES item ignored: %s", item)
|
||
if parsed:
|
||
return parsed
|
||
if MOBILEDE_COMPACT_SEGMENTS:
|
||
return [
|
||
(1, 5000),
|
||
(5001, 10000),
|
||
(10001, 15000),
|
||
(15001, 20000),
|
||
(20001, 30000),
|
||
(30001, 50000),
|
||
(50001, 75000),
|
||
(75001, 100000),
|
||
(100001, 150000),
|
||
(150001, None),
|
||
]
|
||
return [(1, 500), (500, 1000), (1001, 1500), (1501, 2000), (2001, 2500), (2501, 3000), (3001, 4000), (4001, 5000), (5001, 7500), (7501, 10000), (10001, 12500), (12501, 15000), (15001, 17500), (17501, 20000), (20001, 25000), (25001, 30000), (30001, 40000), (40001, 50000), (50001, 75000), (75001, 100000), (100001, 150000), (150001, None)]
|
||
|
||
|
||
def _mobilede_price_ranges_for_segment(segment: dict[str, object] | None = None) -> list[tuple[int, int | None]]:
|
||
make_id = str((segment or {}).get("make_id") or "").strip()
|
||
if make_id == "3500" and MOBILEDE_COMPACT_SEGMENTS:
|
||
# Используем границы, близкие к самим фильтрам mobile.de, чтобы BMW
|
||
# сразу попадали в более плотные корзины и реже дорезались в хвосте.
|
||
return [
|
||
(1, 5000),
|
||
(5001, 10000),
|
||
(10001, 15000),
|
||
(15001, 20000),
|
||
(20001, 25000),
|
||
(25001, 30000),
|
||
(30001, 35000),
|
||
(35001, 40000),
|
||
(40001, 50000),
|
||
(50001, 60000),
|
||
(60001, 80000),
|
||
(80001, 100000),
|
||
(100001, 150000),
|
||
(150001, None),
|
||
]
|
||
return _mobilede_price_ranges()
|
||
|
||
|
||
def _mobilede_year_ranges() -> list[tuple[int | None, int | None]]:
|
||
if MOBILEDE_COMPACT_SEGMENTS:
|
||
return [(None, 2009), (2010, 2017), (2018, 2022), (2023, None)]
|
||
return [(None, 1999), (2000, 2004), (2005, 2009), (2010, 2014), (2015, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
|
||
|
||
|
||
def _mobilede_hot_year_ranges() -> list[tuple[int | None, int | None]]:
|
||
return [(None, 2009), (2010, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
|
||
|
||
|
||
def _mobilede_low_price_hot_year_ranges() -> list[tuple[int | None, int | None]]:
|
||
return [(None, 2004), (2005, 2009), (2010, 2013), (2014, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
|
||
|
||
|
||
def _mobilede_year_ranges_for_price(price_min: int, price_max: int | None) -> list[tuple[int | None, int | None]]:
|
||
if not MOBILEDE_HOT_BASE_SPLIT_ENABLED:
|
||
return _mobilede_year_ranges()
|
||
upper_bound = int(price_max) if price_max is not None else int(price_min)
|
||
if upper_bound <= 10000:
|
||
return _mobilede_low_price_hot_year_ranges()
|
||
if upper_bound <= MOBILEDE_HOT_BASE_PRICE_MAX:
|
||
return _mobilede_hot_year_ranges()
|
||
return _mobilede_year_ranges()
|
||
|
||
|
||
def _mobilede_year_ranges_for_segment_price(
|
||
segment: dict[str, object] | None,
|
||
price_min: int,
|
||
price_max: int | None,
|
||
) -> list[tuple[int | None, int | None]]:
|
||
make_id = str((segment or {}).get("make_id") or "").strip()
|
||
if make_id != "3500":
|
||
return _mobilede_year_ranges_for_price(price_min, price_max)
|
||
|
||
upper_bound = int(price_max) if price_max is not None else int(price_min)
|
||
if upper_bound <= 10000:
|
||
return _mobilede_low_price_hot_year_ranges()
|
||
if upper_bound <= 30000:
|
||
return [(None, 2009), (2010, 2013), (2014, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
|
||
if upper_bound <= 50000:
|
||
return [(None, 2009), (2010, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
|
||
return _mobilede_year_ranges_for_price(price_min, price_max)
|
||
|
||
|
||
def _mobilede_mileage_ranges() -> list[tuple[int | None, int | None]]:
|
||
if MOBILEDE_COMPACT_SEGMENTS:
|
||
return [(None, 100000), (100001, 200000), (200001, None)]
|
||
return [(None, 50000), (50001, 100000), (100001, 150000), (150001, 200000), (200001, None)]
|
||
|
||
|
||
def _mobilede_should_pre_split_mileage(
|
||
price_min: int,
|
||
price_max: int | None,
|
||
year_min: int | None,
|
||
year_max: int | None,
|
||
) -> bool:
|
||
if not MOBILEDE_HOT_MILEAGE_SPLIT_ENABLED:
|
||
return False
|
||
|
||
if MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE:
|
||
return True
|
||
|
||
# Restore the older balanced plan: split mileage only in buckets that are
|
||
# usually dense enough to hit mobile.de's 50-page cap.
|
||
if price_max is not None and price_max <= MOBILEDE_HOT_OLD_CHEAP_PRICE_MAX:
|
||
return year_max is not None and year_max <= 2009
|
||
|
||
if (
|
||
price_max is not None
|
||
and price_max <= 10000
|
||
and year_min is not None
|
||
and year_min >= 2010
|
||
and year_max is not None
|
||
and year_max <= 2017
|
||
):
|
||
return True
|
||
|
||
if (
|
||
price_min >= 10001
|
||
and price_max is not None
|
||
and price_max <= 15000
|
||
and year_min is not None
|
||
and year_min >= 2010
|
||
and year_max is not None
|
||
and year_max <= 2020
|
||
):
|
||
return True
|
||
|
||
if (
|
||
price_min >= 15001
|
||
and price_max is not None
|
||
and price_max <= 30000
|
||
and year_min is not None
|
||
and year_min >= 2021
|
||
):
|
||
return True
|
||
|
||
if (
|
||
price_min >= 30001
|
||
and price_max is not None
|
||
and price_max <= 75000
|
||
and year_min is not None
|
||
and year_min >= 2023
|
||
):
|
||
return True
|
||
|
||
return (
|
||
year_min is not None
|
||
and year_min >= MOBILEDE_HOT_RECENT_YEAR_MIN
|
||
and price_min >= MOBILEDE_HOT_MILEAGE_PRICE_MIN
|
||
and price_max is not None
|
||
and price_max <= MOBILEDE_HOT_MILEAGE_PRICE_MAX
|
||
)
|
||
|
||
|
||
def _mobilede_price_subranges_for_hot_year(
|
||
price_min: int,
|
||
price_max: int | None,
|
||
year_min: int | None,
|
||
year_max: int | None,
|
||
) -> list[tuple[int, int | None]]:
|
||
if price_max is None:
|
||
return [(price_min, price_max)]
|
||
|
||
if year_min is not None and year_min >= 2025:
|
||
if price_min == 15001 and price_max == 20000:
|
||
return [(15001, 17500), (17501, 20000)]
|
||
if price_min == 20001 and price_max == 30000:
|
||
return [(20001, 25000), (25001, 30000)]
|
||
|
||
if year_min is not None and year_min >= 2023:
|
||
if price_min == 20001 and price_max == 30000:
|
||
return [(20001, 25000), (25001, 30000)]
|
||
if price_min == 30001 and price_max == 50000:
|
||
return [(30001, 40000), (40001, 50000)]
|
||
if price_min == 50001 and price_max == 75000:
|
||
return [(50001, 62500), (62501, 75000)]
|
||
|
||
return [(price_min, price_max)]
|
||
|
||
|
||
def _mobilede_target_results_band() -> tuple[int, int, int]:
|
||
target = int(min(MOBILEDE_SEGMENT_TARGET_RESULTS, _mobilede_overflow_threshold(MOBILEDE_MAX_PAGE_NUMBER)))
|
||
lower = max(MOBILEDE_RESULTS_PER_PAGE, int(target * MOBILEDE_SEGMENT_TARGET_MIN_RATIO))
|
||
upper = max(lower, min(target, int(target * MOBILEDE_SEGMENT_TARGET_MAX_RATIO)))
|
||
tiny = max(MOBILEDE_RESULTS_PER_PAGE, int(target * MOBILEDE_SEGMENT_TINY_RATIO))
|
||
return target, lower, tiny
|
||
|
||
|
||
def _mobilede_range_value(min_value: int | None, max_value: int | None) -> str:
|
||
return f"{min_value or ''}:{max_value or ''}"
|
||
|
||
|
||
def _mobilede_price_label(price_min: int, price_max: int | None) -> str:
|
||
return f"price={price_min}-{price_max}" if price_max is not None else f"price={price_min}+"
|
||
|
||
|
||
def _mobilede_year_label(year_min: int | None, year_max: int | None) -> str:
|
||
if year_min is None:
|
||
return f"year<={year_max}"
|
||
if year_max is None:
|
||
return f"year>={year_min}"
|
||
return f"year={year_min}-{year_max}"
|
||
|
||
|
||
def _mobilede_mileage_label(mileage_min: int | None, mileage_max: int | None) -> str:
|
||
if mileage_min is None:
|
||
return f"km<={mileage_max}"
|
||
if mileage_max is None:
|
||
return f"km>={mileage_min}"
|
||
return f"km={mileage_min}-{mileage_max}"
|
||
|
||
|
||
def _mobilede_overflow_threshold(max_pages: int) -> int:
|
||
cap = max(MOBILEDE_RESULTS_PER_PAGE, int(max_pages) * MOBILEDE_RESULTS_PER_PAGE)
|
||
threshold = int(float(cap) * MOBILEDE_OVERFLOW_SPLIT_THRESHOLD_RATIO)
|
||
return max(MOBILEDE_RESULTS_PER_PAGE, min(cap, threshold))
|
||
|
||
|
||
def _mobilede_parse_optional_int(value: object) -> int | None:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return None
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _mobilede_segment_total_results(segment: dict[str, object] | None) -> int | None:
|
||
if not segment:
|
||
return None
|
||
return _mobilede_parse_optional_int(segment.get("total_results"))
|
||
|
||
|
||
def _mobilede_prune_overflow_parent_segments(segments: list[dict[str, object]]) -> list[dict[str, object]]:
|
||
"""Keep only leaf segments once overflow children exist for a parent."""
|
||
parent_keys = {
|
||
str(item.get("overflow_parent") or "").strip()
|
||
for item in segments
|
||
if isinstance(item, dict) and str(item.get("overflow_parent") or "").strip()
|
||
}
|
||
if not parent_keys:
|
||
return [dict(item) for item in segments if isinstance(item, dict)]
|
||
|
||
pruned: list[dict[str, object]] = []
|
||
removed = 0
|
||
for item in segments:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if _mobilede_segment_fingerprint(item) in parent_keys:
|
||
removed += 1
|
||
continue
|
||
pruned.append(dict(item))
|
||
if removed:
|
||
logger.info(
|
||
"mobile.de overflow parent segments pruned: before=%s after=%s removed=%s",
|
||
len(segments),
|
||
len(pruned),
|
||
removed,
|
||
)
|
||
return pruned
|
||
|
||
|
||
def _mobilede_learned_segments_file() -> str:
|
||
return os.getenv("MOBILEDE_LEARNED_SEGMENTS_FILE", "/data/mobilede_runtime_segments.json").strip()
|
||
|
||
|
||
def _mobilede_segments_source_fingerprint(segments: list[dict[str, object]]) -> str:
|
||
source_urls = [
|
||
str(segment.get("source_search_url") or segment.get("search_url") or segment.get("listing_url") or "").strip()
|
||
for segment in segments
|
||
if isinstance(segment, dict)
|
||
]
|
||
source = json.dumps(sorted(url for url in source_urls if url), ensure_ascii=False, separators=(",", ":"))
|
||
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:16]
|
||
|
||
|
||
def _mobilede_load_learned_runtime_segments(source_segments: list[dict[str, object]]) -> list[dict[str, object]] | None:
|
||
path = _mobilede_learned_segments_file()
|
||
if not path:
|
||
return None
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
payload = json.load(fh)
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
if payload.get("plan_version") != 1:
|
||
return None
|
||
expected = _mobilede_segments_source_fingerprint(source_segments)
|
||
if str(payload.get("source_fingerprint") or "") != expected:
|
||
logger.info(
|
||
"mobile.de learned segments ignored: source fingerprint changed expected=%s actual=%s",
|
||
expected,
|
||
payload.get("source_fingerprint"),
|
||
)
|
||
return None
|
||
raw_segments = payload.get("segments")
|
||
if not isinstance(raw_segments, list):
|
||
return None
|
||
learned = _mobilede_prune_overflow_parent_segments([dict(item) for item in raw_segments if isinstance(item, dict)])
|
||
if not learned:
|
||
return None
|
||
logger.info("mobile.de learned runtime segments loaded: file=%s count=%s", path, len(learned))
|
||
return learned
|
||
except FileNotFoundError:
|
||
return None
|
||
except Exception:
|
||
logger.warning("mobile.de learned runtime segments load failed: file=%s", path, exc_info=True)
|
||
return None
|
||
|
||
|
||
def _mobilede_save_learned_runtime_segments(
|
||
source_segments: list[dict[str, object]],
|
||
runtime_segments: list[dict[str, object]],
|
||
) -> None:
|
||
path = _mobilede_learned_segments_file()
|
||
if not path or not runtime_segments:
|
||
return
|
||
try:
|
||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||
pruned_segments = _mobilede_prune_overflow_parent_segments(runtime_segments)
|
||
payload = {
|
||
"plan_version": 1,
|
||
"source_fingerprint": _mobilede_segments_source_fingerprint(source_segments),
|
||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||
"segments": pruned_segments,
|
||
}
|
||
tmp_path = f"{path}.tmp"
|
||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||
json.dump(payload, fh, ensure_ascii=False, separators=(",", ":"))
|
||
os.replace(tmp_path, path)
|
||
logger.info("mobile.de learned runtime segments saved: file=%s count=%s", path, len(pruned_segments))
|
||
except Exception:
|
||
logger.warning("mobile.de learned runtime segments save failed: file=%s", path, exc_info=True)
|
||
|
||
|
||
def _mobilede_normalize_make_name(value: str) -> str:
|
||
text = unicodedata.normalize("NFKD", str(value or ""))
|
||
ascii_text = text.encode("ascii", "ignore").decode("ascii")
|
||
return re.sub(r"[^a-z0-9]+", "", ascii_text.casefold())
|
||
|
||
|
||
def _mobilede_runtime_filter_brands(settings: Settings) -> tuple[str, ...]:
|
||
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
|
||
return tuple(runtime_config.filters.include.brands)
|
||
|
||
|
||
def _mobilede_make_alias_candidates(value: str) -> tuple[str, ...]:
|
||
normalized = _mobilede_normalize_make_name(value)
|
||
aliases = _MOBILEDE_RUNTIME_BRAND_ALIASES.get(normalized, ())
|
||
candidates = [normalized, *aliases]
|
||
deduped: list[str] = []
|
||
for candidate in candidates:
|
||
if candidate and candidate not in deduped:
|
||
deduped.append(candidate)
|
||
return tuple(deduped)
|
||
|
||
|
||
def _mobilede_strip_query_keys(search_url: str, keys: set[str]) -> str:
|
||
parts = urlsplit(search_url)
|
||
query_items = [
|
||
(key, value)
|
||
for key, value in parse_qsl(parts.query, keep_blank_values=True)
|
||
if key not in keys
|
||
]
|
||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query_items, doseq=True), parts.fragment))
|
||
|
||
|
||
def _mobilede_refdata_make_keys() -> dict[str, str]:
|
||
global _mobilede_refdata_make_keys_cache
|
||
now = time.monotonic()
|
||
if _mobilede_refdata_make_keys_cache is not None:
|
||
cached_at, cached = _mobilede_refdata_make_keys_cache
|
||
if now - cached_at < MOBILEDE_SITE_MAKE_OPTIONS_CACHE_TTL_SECONDS and cached:
|
||
return cached
|
||
|
||
try:
|
||
response = requests.get(
|
||
MOBILEDE_REFDATA_CAR_MAKES_URL,
|
||
headers={"Accept": "application/xml", "Accept-Language": "en"},
|
||
timeout=60,
|
||
)
|
||
response.raise_for_status()
|
||
root = ElementTree.fromstring(response.text)
|
||
make_keys: dict[str, str] = {}
|
||
for item in root.iter():
|
||
key = str(item.attrib.get("key") or "").strip()
|
||
normalized = _mobilede_normalize_make_name(key)
|
||
if key and normalized and normalized not in make_keys:
|
||
make_keys[normalized] = key
|
||
for alias in _MOBILEDE_REFDATA_MAKE_KEY_ALIASES.get(normalized, ()):
|
||
if alias and alias not in make_keys:
|
||
make_keys[alias] = key
|
||
_mobilede_refdata_make_keys_cache = (now, make_keys)
|
||
return make_keys
|
||
except Exception:
|
||
logger.warning("mobile.de refdata make keys load failed", exc_info=True)
|
||
if _mobilede_refdata_make_keys_cache is not None:
|
||
return _mobilede_refdata_make_keys_cache[1]
|
||
return {}
|
||
|
||
|
||
def _mobilede_parse_site_make_options(html: str) -> dict[str, str]:
|
||
official_make_keys = _mobilede_refdata_make_keys()
|
||
if not official_make_keys:
|
||
return {}
|
||
options: dict[str, str] = {}
|
||
for raw_label, raw_value in _MOBILEDE_SITE_MAKE_OPTION_RE.findall(html):
|
||
label = str(raw_label or "").strip()
|
||
value = str(raw_value or "").strip()
|
||
normalized = _mobilede_normalize_make_name(label)
|
||
if not label or not value.isdigit() or not normalized:
|
||
continue
|
||
if normalized not in official_make_keys:
|
||
continue
|
||
options.setdefault(label, value)
|
||
return options
|
||
|
||
|
||
def _mobilede_fetch_site_make_options(search_url: str) -> dict[str, str]:
|
||
base_url = _mobilede_strip_query_keys(search_url, {"ms"})
|
||
now = time.monotonic()
|
||
cached = _mobilede_site_make_options_cache.get(base_url)
|
||
if cached is not None:
|
||
cached_at, options = cached
|
||
if now - cached_at < MOBILEDE_SITE_MAKE_OPTIONS_CACHE_TTL_SECONDS and options:
|
||
return options
|
||
|
||
client = MobileDeClient.for_worker(delay_seconds=0)
|
||
html = client.fetch_html(base_url, timeout=60)
|
||
options = _mobilede_parse_site_make_options(html)
|
||
if options:
|
||
_mobilede_site_make_options_cache[base_url] = (now, options)
|
||
return options
|
||
|
||
|
||
def _mobilede_match_site_make_option(
|
||
brand_name: str,
|
||
make_options: dict[str, str],
|
||
) -> tuple[str, str] | None:
|
||
option_by_normalized = {
|
||
_mobilede_normalize_make_name(label): (label, value)
|
||
for label, value in make_options.items()
|
||
}
|
||
for candidate in _mobilede_make_alias_candidates(brand_name):
|
||
match = option_by_normalized.get(candidate)
|
||
if match is not None:
|
||
return match
|
||
return None
|
||
|
||
|
||
def _mobilede_source_segments_from_runtime_brands(
|
||
settings: Settings,
|
||
env_search_urls: list[str],
|
||
) -> list[dict[str, object]]:
|
||
if not MOBILEDE_RUNTIME_BRAND_SEGMENTS_ENABLED or not env_search_urls:
|
||
return []
|
||
|
||
runtime_brands = list(_mobilede_runtime_filter_brands(settings))
|
||
derived_segments: list[dict[str, object]] = []
|
||
seen_fingerprints: set[str] = set()
|
||
missing_brands: set[str] = set()
|
||
|
||
for index, search_url in enumerate(env_search_urls, start=1):
|
||
base_url = _mobilede_strip_query_keys(search_url, {"ms"})
|
||
try:
|
||
make_options = _mobilede_fetch_site_make_options(base_url)
|
||
except Exception as exc:
|
||
logger.warning("mobile.de site make options fetch failed: url=%s error=%s", base_url, exc)
|
||
continue
|
||
if not make_options:
|
||
continue
|
||
|
||
if runtime_brands:
|
||
selected: list[tuple[str, str, str]] = []
|
||
for brand_name in runtime_brands:
|
||
match = _mobilede_match_site_make_option(brand_name, make_options)
|
||
if match is None:
|
||
missing_brands.add(brand_name)
|
||
continue
|
||
site_label, make_token = match
|
||
selected.append((brand_name, site_label, make_token))
|
||
else:
|
||
selected = [(site_label, site_label, make_token) for site_label, make_token in sorted(make_options.items())]
|
||
|
||
base_label = "Cars" if len(env_search_urls) == 1 else f"Cars {index}"
|
||
for requested_brand, site_label, make_token in selected:
|
||
item = {
|
||
"label": f"{site_label} | ms={make_token}" if len(env_search_urls) == 1 else f"{base_label} | {site_label} | ms={make_token}",
|
||
"make": site_label,
|
||
"make_id": make_token,
|
||
"search_url": _mobilede_make_segment_url(base_url, ms=make_token),
|
||
"source_search_url": base_url,
|
||
"start_page": 1,
|
||
"max_pages": MOBILEDE_MAX_PAGE_NUMBER,
|
||
"auto_segment": True,
|
||
"runtime_brand": requested_brand,
|
||
}
|
||
fingerprint = _mobilede_segment_fingerprint(item)
|
||
if fingerprint in seen_fingerprints:
|
||
continue
|
||
seen_fingerprints.add(fingerprint)
|
||
derived_segments.append(item)
|
||
|
||
if missing_brands:
|
||
logger.warning(
|
||
"mobile.de runtime brands skipped: missing=%s brands=%s",
|
||
len(missing_brands),
|
||
", ".join(sorted(missing_brands)),
|
||
)
|
||
if derived_segments:
|
||
logger.info(
|
||
"mobile.de runtime brand source segments ready: templates=%s requested_brands=%s segments=%s",
|
||
len(env_search_urls),
|
||
len(runtime_brands) if runtime_brands else len(derived_segments),
|
||
len(derived_segments),
|
||
)
|
||
return derived_segments
|
||
|
||
|
||
def _mobilede_source_segments_from_settings(settings: Settings) -> list[dict[str, object]]:
|
||
env_search_urls = settings.listing.filtered_search_urls
|
||
if env_search_urls:
|
||
runtime_brand_segments = _mobilede_source_segments_from_runtime_brands(settings, env_search_urls)
|
||
if runtime_brand_segments:
|
||
return runtime_brand_segments
|
||
return [
|
||
{
|
||
"label": "Cars" if len(env_search_urls) == 1 else f"Cars {index}",
|
||
"search_url": search_url,
|
||
"source_search_url": search_url,
|
||
"start_page": 1,
|
||
"max_pages": MOBILEDE_MAX_PAGE_NUMBER,
|
||
}
|
||
for index, search_url in enumerate(env_search_urls, start=1)
|
||
]
|
||
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
|
||
return [segment.to_task_kwargs() for segment in runtime_config.mobilede.segments]
|
||
|
||
|
||
def _mobilede_split_year_ranges_for_overflow(
|
||
year_min: int | None,
|
||
year_max: int | None,
|
||
) -> list[tuple[int | None, int | None]]:
|
||
current_year = int(time.gmtime().tm_year)
|
||
if year_min is None and year_max is None:
|
||
return []
|
||
if year_min is None and year_max is not None:
|
||
pivot = int(year_max) - 5
|
||
if pivot <= 0 or pivot >= int(year_max):
|
||
return []
|
||
return [(None, pivot), (pivot + 1, int(year_max))]
|
||
if year_min is not None and year_max is None:
|
||
if int(year_min) >= current_year - 1:
|
||
return []
|
||
pivot = min(current_year - 2, int(year_min) + 2)
|
||
if pivot <= int(year_min):
|
||
return []
|
||
return [(int(year_min), pivot), (pivot + 1, None)]
|
||
|
||
# Оба значения заданы.
|
||
assert year_min is not None and year_max is not None
|
||
span = int(year_max) - int(year_min)
|
||
if span < MOBILEDE_OVERFLOW_MIN_YEAR_SPLIT_SPAN:
|
||
return []
|
||
pivot = int(year_min) + span // 2
|
||
if pivot <= int(year_min) or pivot >= int(year_max):
|
||
return []
|
||
return [(int(year_min), pivot), (pivot + 1, int(year_max))]
|
||
|
||
|
||
def _mobilede_split_price_ranges_for_overflow(
|
||
price_min: int | None,
|
||
price_max: int | None,
|
||
) -> list[tuple[int, int | None]]:
|
||
left = int(price_min or 1)
|
||
right = price_max
|
||
if right is None:
|
||
step = max(5000, min(50000, left))
|
||
pivot = left + step
|
||
return [(left, pivot), (pivot + 1, None)]
|
||
span = int(right) - int(left)
|
||
if span < MOBILEDE_OVERFLOW_MIN_PRICE_SPLIT_SPAN:
|
||
return []
|
||
pivot = int(left) + span // 2
|
||
if pivot <= int(left) or pivot >= int(right):
|
||
return []
|
||
return [(int(left), pivot), (pivot + 1, int(right))]
|
||
|
||
|
||
def _mobilede_split_mileage_ranges_for_overflow(
|
||
mileage_min: int | None,
|
||
mileage_max: int | None,
|
||
) -> list[tuple[int | None, int | None]]:
|
||
if mileage_min is None and mileage_max is None:
|
||
return []
|
||
if mileage_min is None and mileage_max is not None:
|
||
right = int(mileage_max)
|
||
if right <= 1:
|
||
return []
|
||
if right <= 10000:
|
||
return _mobilede_split_fine_mileage_ranges(None, right)
|
||
pivot = right // 2
|
||
if pivot <= 0 or pivot >= right:
|
||
return []
|
||
return [(None, pivot), (pivot + 1, right)]
|
||
if mileage_min is not None and mileage_max is None:
|
||
left = int(mileage_min)
|
||
step = max(25000, min(100000, left))
|
||
pivot = left + step
|
||
return [(left, pivot), (pivot + 1, None)]
|
||
|
||
assert mileage_min is not None and mileage_max is not None
|
||
left = int(mileage_min)
|
||
right = int(mileage_max)
|
||
span = right - left
|
||
if span < 10000:
|
||
return _mobilede_split_fine_mileage_ranges(left, right)
|
||
pivot = left + span // 2
|
||
if pivot <= left or pivot >= right:
|
||
return []
|
||
return [(left, pivot), (pivot + 1, right)]
|
||
|
||
|
||
def _mobilede_root_mileage_ranges_for_overflow(max_children: int) -> list[tuple[int | None, int | None]]:
|
||
child_budget = max(0, int(max_children))
|
||
if child_budget < 2:
|
||
return []
|
||
if child_budget == 2:
|
||
pivot = 150000 if MOBILEDE_COMPACT_SEGMENTS else 100000
|
||
return [(None, pivot), (pivot + 1, None)]
|
||
if child_budget == 3:
|
||
pivot = 150000 if MOBILEDE_COMPACT_SEGMENTS else 100000
|
||
low_cap = 75000 if MOBILEDE_COMPACT_SEGMENTS else 50000
|
||
return [(None, low_cap), (low_cap + 1, pivot), (pivot + 1, None)]
|
||
return _mobilede_mileage_ranges()
|
||
|
||
|
||
def _mobilede_split_fine_mileage_ranges(
|
||
mileage_min: int | None,
|
||
mileage_max: int | None,
|
||
) -> list[tuple[int | None, int | None]]:
|
||
if mileage_max is None:
|
||
return []
|
||
|
||
left = int(mileage_min or 0)
|
||
right = int(mileage_max)
|
||
if right <= left:
|
||
return []
|
||
|
||
span = right - left
|
||
if span >= 5000:
|
||
pivot = left + span // 2
|
||
elif span >= 1000:
|
||
pivot = left + max(1, span // 2)
|
||
else:
|
||
return []
|
||
|
||
if pivot <= left or pivot >= right:
|
||
return []
|
||
first_min = None if mileage_min is None and left == 0 else left
|
||
return [(first_min, pivot), (pivot + 1, right)]
|
||
|
||
|
||
def _mobilede_range_span(value_min: int | None, value_max: int | None) -> int | None:
|
||
if value_min is None or value_max is None:
|
||
return None
|
||
return max(0, int(value_max) - int(value_min))
|
||
|
||
|
||
def _mobilede_should_avoid_bmw_mileage_split(
|
||
*,
|
||
depth: int,
|
||
price_min: int | None,
|
||
price_max: int | None,
|
||
mileage_min: int | None,
|
||
mileage_max: int | None,
|
||
previous_split_kind: str,
|
||
) -> bool:
|
||
price_span = _mobilede_range_span(price_min, price_max)
|
||
mileage_span = _mobilede_range_span(mileage_min, mileage_max)
|
||
|
||
if mileage_span is not None and mileage_span <= 25000:
|
||
return True
|
||
if previous_split_kind == "mileage" and mileage_span is not None and mileage_span <= 50000:
|
||
return True
|
||
if depth >= 2 and mileage_span is not None and mileage_span <= 100000:
|
||
return True
|
||
if price_span is not None and price_span <= 5000 and mileage_span is not None and mileage_span <= 100000:
|
||
return True
|
||
if price_span is not None and price_span <= 10000 and previous_split_kind == "mileage":
|
||
return True
|
||
return False
|
||
|
||
|
||
def _mobilede_make_overflow_child_segment(
|
||
segment: dict[str, object],
|
||
*,
|
||
search_url: str,
|
||
max_pages: int,
|
||
parent_fingerprint: str,
|
||
depth: int,
|
||
split_kind: str,
|
||
split_label: str,
|
||
split_params: dict[str, str],
|
||
price_range: tuple[int | None, int | None] | None = None,
|
||
year_range: tuple[int | None, int | None] | None = None,
|
||
mileage_range: tuple[int | None, int | None] | None = None,
|
||
) -> dict[str, object]:
|
||
child = dict(segment)
|
||
child["search_url"] = _mobilede_make_segment_url(search_url, **split_params)
|
||
child["listing_url"] = child["search_url"]
|
||
child["start_page"] = 1
|
||
child["max_pages"] = max_pages
|
||
child["total_results"] = None
|
||
child["overflow_parent"] = parent_fingerprint
|
||
child["overflow_depth"] = depth + 1
|
||
child["overflow_split"] = split_kind
|
||
child["label"] = f"{str(segment.get('label') or 'mobile.de overflow')} | {split_label} | overflow:d{depth + 1}"
|
||
|
||
if price_range is not None:
|
||
price_min, price_max = price_range
|
||
child["price_min"] = str(price_min) if price_min is not None else None
|
||
child["price_max"] = str(price_max) if price_max is not None else None
|
||
if year_range is not None:
|
||
year_min, year_max = year_range
|
||
child["year_min"] = str(year_min) if year_min is not None else None
|
||
child["year_max"] = str(year_max) if year_max is not None else None
|
||
if mileage_range is not None:
|
||
mileage_min, mileage_max = mileage_range
|
||
child["mileage_min"] = str(mileage_min) if mileage_min is not None else None
|
||
child["mileage_max"] = str(mileage_max) if mileage_max is not None else None
|
||
return child
|
||
|
||
|
||
def _mobilede_append_overflow_candidate_group(
|
||
groups: list[tuple[str, list[dict[str, object]]]],
|
||
kind: str,
|
||
children: list[dict[str, object]],
|
||
) -> None:
|
||
if not children:
|
||
return
|
||
fingerprints = tuple(_mobilede_segment_fingerprint(child) for child in children)
|
||
if not all(fingerprints):
|
||
return
|
||
for existing_kind, existing_children in groups:
|
||
existing_fingerprints = tuple(_mobilede_segment_fingerprint(child) for child in existing_children)
|
||
if existing_kind == kind or existing_fingerprints == fingerprints:
|
||
return
|
||
groups.append((kind, children))
|
||
|
||
|
||
def _mobilede_build_overflow_candidate_groups(
|
||
*,
|
||
segment: dict[str, object],
|
||
max_pages: int,
|
||
) -> list[tuple[str, list[dict[str, object]]]]:
|
||
if MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS <= 0:
|
||
return []
|
||
|
||
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
|
||
if not search_url:
|
||
return []
|
||
|
||
depth = max(0, int(_mobilede_parse_optional_int(segment.get("overflow_depth")) or 0))
|
||
if depth >= MOBILEDE_OVERFLOW_MAX_SPLIT_DEPTH:
|
||
return []
|
||
|
||
query_keys = {key for key, _ in parse_qsl(urlsplit(search_url).query, keep_blank_values=True)}
|
||
parent_fingerprint = _mobilede_segment_fingerprint(segment)
|
||
segment_max_pages = max(1, int(max_pages or segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER))
|
||
candidate_groups: list[tuple[str, list[dict[str, object]]]] = []
|
||
|
||
has_mileage_filter = bool(
|
||
str(segment.get("mileage_min") or "").strip()
|
||
or str(segment.get("mileage_max") or "").strip()
|
||
or "ml" in query_keys
|
||
)
|
||
|
||
mileage_min = _mobilede_parse_optional_int(segment.get("mileage_min"))
|
||
mileage_max = _mobilede_parse_optional_int(segment.get("mileage_max"))
|
||
year_min = _mobilede_parse_optional_int(segment.get("year_min"))
|
||
year_max = _mobilede_parse_optional_int(segment.get("year_max"))
|
||
price_min = _mobilede_parse_optional_int(segment.get("price_min"))
|
||
price_max = _mobilede_parse_optional_int(segment.get("price_max"))
|
||
make_id = str(segment.get("make_id") or "").strip()
|
||
previous_split_kind = str(segment.get("overflow_split") or "").strip()
|
||
mileage_splits = _mobilede_split_mileage_ranges_for_overflow(mileage_min, mileage_max)
|
||
if make_id == "3500" and mileage_splits:
|
||
if _mobilede_should_avoid_bmw_mileage_split(
|
||
depth=depth,
|
||
price_min=price_min,
|
||
price_max=price_max,
|
||
mileage_min=mileage_min,
|
||
mileage_max=mileage_max,
|
||
previous_split_kind=previous_split_kind,
|
||
):
|
||
mileage_splits = []
|
||
|
||
if not has_mileage_filter:
|
||
child_segments: list[dict[str, object]] = []
|
||
for child_mileage_min, child_mileage_max in _mobilede_root_mileage_ranges_for_overflow(MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS):
|
||
child_segments.append(
|
||
_mobilede_make_overflow_child_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
max_pages=segment_max_pages,
|
||
parent_fingerprint=parent_fingerprint,
|
||
depth=depth,
|
||
split_kind="mileage",
|
||
split_label=_mobilede_mileage_label(child_mileage_min, child_mileage_max),
|
||
split_params={"ml": _mobilede_range_value(child_mileage_min, child_mileage_max)},
|
||
mileage_range=(child_mileage_min, child_mileage_max),
|
||
)
|
||
)
|
||
_mobilede_append_overflow_candidate_group(candidate_groups, "root_mileage", child_segments)
|
||
|
||
price_splits = _mobilede_split_price_ranges_for_overflow(price_min, price_max)
|
||
if price_splits:
|
||
child_segments = []
|
||
for child_price_min, child_price_max in price_splits[:2]:
|
||
child_segments.append(
|
||
_mobilede_make_overflow_child_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
max_pages=segment_max_pages,
|
||
parent_fingerprint=parent_fingerprint,
|
||
depth=depth,
|
||
split_kind="price",
|
||
split_label=_mobilede_price_label(child_price_min, child_price_max),
|
||
split_params={"p": _mobilede_range_value(child_price_min, child_price_max)},
|
||
price_range=(child_price_min, child_price_max),
|
||
)
|
||
)
|
||
price_kind = "recent_price" if year_min is not None and year_min >= 2023 else "price"
|
||
_mobilede_append_overflow_candidate_group(candidate_groups, price_kind, child_segments)
|
||
|
||
year_span = None
|
||
if year_min is not None and year_max is not None:
|
||
year_span = int(year_max) - int(year_min)
|
||
|
||
allow_year_split = not (
|
||
previous_split_kind == "year"
|
||
and depth >= MOBILEDE_OVERFLOW_YEAR_DEEP_SPLIT_DEPTH
|
||
)
|
||
if allow_year_split and make_id == "3500":
|
||
# Для BMW не уходим в слишком узкие year-ветки.
|
||
if year_span is not None and year_span < max(4, MOBILEDE_OVERFLOW_MIN_YEAR_SPLIT_SPAN):
|
||
allow_year_split = False
|
||
elif price_splits or mileage_splits:
|
||
allow_year_split = False
|
||
|
||
year_splits = _mobilede_split_year_ranges_for_overflow(year_min, year_max) if allow_year_split else []
|
||
if year_splits:
|
||
child_segments = []
|
||
for child_year_min, child_year_max in year_splits[:2]:
|
||
child_segments.append(
|
||
_mobilede_make_overflow_child_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
max_pages=segment_max_pages,
|
||
parent_fingerprint=parent_fingerprint,
|
||
depth=depth,
|
||
split_kind="year",
|
||
split_label=_mobilede_year_label(child_year_min, child_year_max),
|
||
split_params={"fr": _mobilede_range_value(child_year_min, child_year_max)},
|
||
year_range=(child_year_min, child_year_max),
|
||
)
|
||
)
|
||
_mobilede_append_overflow_candidate_group(candidate_groups, "year", child_segments)
|
||
|
||
if mileage_splits:
|
||
child_segments = []
|
||
for child_mileage_min, child_mileage_max in mileage_splits[:2]:
|
||
child_segments.append(
|
||
_mobilede_make_overflow_child_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
max_pages=segment_max_pages,
|
||
parent_fingerprint=parent_fingerprint,
|
||
depth=depth,
|
||
split_kind="mileage",
|
||
split_label=_mobilede_mileage_label(child_mileage_min, child_mileage_max),
|
||
split_params={"ml": _mobilede_range_value(child_mileage_min, child_mileage_max)},
|
||
mileage_range=(child_mileage_min, child_mileage_max),
|
||
)
|
||
)
|
||
_mobilede_append_overflow_candidate_group(candidate_groups, "mileage", child_segments)
|
||
|
||
if make_id == "11000":
|
||
candidate_groups.sort(key=lambda item: {"price": 0, "recent_price": 0, "year": 1, "root_mileage": 2, "mileage": 3}.get(item[0], 9))
|
||
elif make_id == "3500":
|
||
candidate_groups.sort(key=lambda item: {"recent_price": 0, "price": 1, "year": 2, "mileage": 3, "root_mileage": 4}.get(item[0], 9))
|
||
|
||
return candidate_groups
|
||
|
||
|
||
def _mobilede_score_overflow_candidate_group(
|
||
segment: dict[str, object],
|
||
kind: str,
|
||
children: list[dict[str, object]],
|
||
) -> tuple[int, int, int, int, int, int, int] | None:
|
||
if not children:
|
||
return None
|
||
|
||
parent_total = _mobilede_segment_total_results(segment)
|
||
page_cap = max(MOBILEDE_RESULTS_PER_PAGE, MOBILEDE_MAX_PAGE_NUMBER * MOBILEDE_RESULTS_PER_PAGE)
|
||
overflow_children = 0
|
||
unresolved_children = 0
|
||
max_child_total = 0
|
||
sum_child_total = 0
|
||
tiny_children = 0
|
||
micro_children = 0
|
||
narrow_year_children = 0
|
||
target_band_misses = 0
|
||
distance_to_target = 0
|
||
probed = 0
|
||
depth = max(0, int(_mobilede_parse_optional_int(segment.get("overflow_depth")) or 0))
|
||
price_min = _mobilede_parse_optional_int(segment.get("price_min"))
|
||
price_max = _mobilede_parse_optional_int(segment.get("price_max"))
|
||
mileage_min = _mobilede_parse_optional_int(segment.get("mileage_min"))
|
||
mileage_max = _mobilede_parse_optional_int(segment.get("mileage_max"))
|
||
price_span = _mobilede_range_span(price_min, price_max)
|
||
mileage_span = _mobilede_range_span(mileage_min, mileage_max)
|
||
make_id = str(segment.get("make_id") or "").strip()
|
||
previous_split_kind = str(segment.get("overflow_split") or "").strip()
|
||
target, lower_target, tiny_threshold = _mobilede_target_results_band()
|
||
|
||
for child in children[:MOBILEDE_OVERFLOW_SPLIT_PROBE_CHILDREN]:
|
||
child_total = _mobilede_probe_segment_total(child)
|
||
if child_total is None:
|
||
unresolved_children += 1
|
||
continue
|
||
child["total_results"] = child_total
|
||
probed += 1
|
||
sum_child_total += int(child_total)
|
||
max_child_total = max(max_child_total, int(child_total))
|
||
if int(child_total) > page_cap:
|
||
overflow_children += 1
|
||
if int(child_total) < tiny_threshold:
|
||
tiny_children += 1
|
||
if int(child_total) < lower_target:
|
||
micro_children += 1
|
||
if not (lower_target <= int(child_total) <= target):
|
||
target_band_misses += 1
|
||
distance_to_target += abs(target - int(child_total))
|
||
child_year_min = _mobilede_parse_optional_int(child.get("year_min"))
|
||
child_year_max = _mobilede_parse_optional_int(child.get("year_max"))
|
||
if (
|
||
child_year_min is not None
|
||
and child_year_max is not None
|
||
and int(child_year_max) - int(child_year_min) <= 1
|
||
):
|
||
narrow_year_children += 1
|
||
|
||
if probed == 0:
|
||
return None
|
||
|
||
if parent_total is None or parent_total <= 0:
|
||
coverage_gap = 0
|
||
else:
|
||
coverage_gap = abs(int(parent_total) - int(sum_child_total))
|
||
|
||
split_bias = {
|
||
"recent_price": 0,
|
||
"price": 1,
|
||
"year": 2,
|
||
"mileage": 3,
|
||
"root_mileage": 4,
|
||
}.get(kind, 5)
|
||
if kind == "year":
|
||
split_bias += MOBILEDE_OVERFLOW_SCORE_YEAR_PENALTY
|
||
split_bias += depth * MOBILEDE_OVERFLOW_SCORE_DEPTH_PENALTY
|
||
split_bias += narrow_year_children * MOBILEDE_OVERFLOW_SCORE_MICRO_CHILD_PENALTY
|
||
elif kind in {"mileage", "root_mileage"}:
|
||
split_bias += MOBILEDE_OVERFLOW_SCORE_MILEAGE_PENALTY
|
||
split_bias += depth * max(1, MOBILEDE_OVERFLOW_SCORE_DEPTH_PENALTY // 2)
|
||
if kind == "mileage" and previous_split_kind == "mileage":
|
||
split_bias += MOBILEDE_OVERFLOW_SCORE_DEPTH_PENALTY + MOBILEDE_OVERFLOW_SCORE_MICRO_CHILD_PENALTY
|
||
if make_id == "3500":
|
||
if price_span is not None and price_span <= 10000:
|
||
split_bias += MOBILEDE_OVERFLOW_SCORE_MICRO_CHILD_PENALTY
|
||
if price_span is not None and price_span <= 5000:
|
||
split_bias += MOBILEDE_OVERFLOW_SCORE_MICRO_CHILD_PENALTY
|
||
if mileage_span is not None and mileage_span <= 100000:
|
||
split_bias += max(1, MOBILEDE_OVERFLOW_SCORE_DEPTH_PENALTY // 2)
|
||
if mileage_span is not None and mileage_span <= 50000:
|
||
split_bias += MOBILEDE_OVERFLOW_SCORE_MICRO_CHILD_PENALTY
|
||
if mileage_span is not None and mileage_span <= 25000:
|
||
split_bias += MOBILEDE_OVERFLOW_SCORE_DEPTH_PENALTY + MOBILEDE_OVERFLOW_SCORE_MICRO_CHILD_PENALTY
|
||
|
||
distance_to_target += micro_children * max(1, MOBILEDE_OVERFLOW_SCORE_MICRO_CHILD_PENALTY // 2)
|
||
|
||
return (
|
||
overflow_children,
|
||
tiny_children + micro_children + narrow_year_children,
|
||
target_band_misses,
|
||
distance_to_target,
|
||
coverage_gap,
|
||
max_child_total,
|
||
split_bias + unresolved_children,
|
||
)
|
||
|
||
|
||
def _mobilede_overflow_candidate_group_is_useful(
|
||
segment: dict[str, object],
|
||
kind: str,
|
||
children: list[dict[str, object]],
|
||
) -> bool:
|
||
if not children:
|
||
return False
|
||
|
||
target, lower_target, tiny_threshold = _mobilede_target_results_band()
|
||
useful_floor = max(MOBILEDE_RESULTS_PER_PAGE, int(target * MOBILEDE_OVERFLOW_MIN_USEFUL_CHILD_RATIO))
|
||
child_totals = [
|
||
int(total)
|
||
for total in (
|
||
_mobilede_parse_optional_int(child.get("total_results"))
|
||
for child in children[:MOBILEDE_OVERFLOW_SPLIT_PROBE_CHILDREN]
|
||
)
|
||
if total is not None
|
||
]
|
||
if not child_totals:
|
||
return True
|
||
|
||
useful_children = sum(total >= useful_floor for total in child_totals)
|
||
micro_children = sum(total < lower_target for total in child_totals)
|
||
tiny_children = sum(total < tiny_threshold for total in child_totals)
|
||
max_child_total = max(child_totals)
|
||
make_id = str(segment.get("make_id") or "").strip()
|
||
depth = max(0, int(_mobilede_parse_optional_int(segment.get("overflow_depth")) or 0))
|
||
previous_split_kind = str(segment.get("overflow_split") or "").strip()
|
||
price_span = _mobilede_range_span(
|
||
_mobilede_parse_optional_int(segment.get("price_min")),
|
||
_mobilede_parse_optional_int(segment.get("price_max")),
|
||
)
|
||
mileage_span = _mobilede_range_span(
|
||
_mobilede_parse_optional_int(segment.get("mileage_min")),
|
||
_mobilede_parse_optional_int(segment.get("mileage_max")),
|
||
)
|
||
|
||
if tiny_children >= len(child_totals):
|
||
return False
|
||
if micro_children > MOBILEDE_OVERFLOW_MAX_MICRO_CHILDREN and useful_children == 0:
|
||
return False
|
||
|
||
if make_id == "3500":
|
||
if kind in {"mileage", "root_mileage"}:
|
||
if previous_split_kind == "mileage" and useful_children == 0:
|
||
return False
|
||
if depth >= 2 and useful_children == 0:
|
||
return False
|
||
if price_span is not None and price_span <= 10000 and useful_children == 0:
|
||
return False
|
||
if mileage_span is not None and mileage_span <= 100000 and micro_children > 0 and useful_children == 0:
|
||
return False
|
||
if kind == "year" and useful_children == 0 and max_child_total < lower_target:
|
||
return False
|
||
|
||
return useful_children > 0 or max_child_total >= lower_target
|
||
|
||
|
||
def _mobilede_build_overflow_child_segments(
|
||
*,
|
||
segment: dict[str, object],
|
||
max_pages: int,
|
||
probe_best_split: bool = False,
|
||
) -> list[dict[str, object]]:
|
||
candidate_groups = _mobilede_build_overflow_candidate_groups(segment=segment, max_pages=max_pages)
|
||
if not candidate_groups:
|
||
return []
|
||
if not probe_best_split or not MOBILEDE_OVERFLOW_SMART_SPLIT_ENABLED or len(candidate_groups) == 1:
|
||
return candidate_groups[0][1]
|
||
|
||
best_kind, best_children = candidate_groups[0]
|
||
best_score = _mobilede_score_overflow_candidate_group(segment, best_kind, best_children)
|
||
for kind, children in candidate_groups[1:MOBILEDE_OVERFLOW_SPLIT_PROBE_CANDIDATES]:
|
||
score = _mobilede_score_overflow_candidate_group(segment, kind, children)
|
||
if score is None:
|
||
continue
|
||
if best_score is None or score < best_score:
|
||
best_kind = kind
|
||
best_children = children
|
||
best_score = score
|
||
|
||
if best_score is not None:
|
||
logger.info(
|
||
"mobile.de overflow split selected: segment=%s split=%s score=%s candidates=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
best_kind,
|
||
best_score,
|
||
", ".join(kind for kind, _children in candidate_groups[:MOBILEDE_OVERFLOW_SPLIT_PROBE_CANDIDATES]),
|
||
)
|
||
if not _mobilede_overflow_candidate_group_is_useful(segment, best_kind, best_children):
|
||
logger.info(
|
||
"mobile.de overflow split rejected as low-yield: segment=%s split=%s child_totals=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
best_kind,
|
||
[
|
||
_mobilede_parse_optional_int(child.get("total_results"))
|
||
for child in best_children[:MOBILEDE_OVERFLOW_SPLIT_PROBE_CHILDREN]
|
||
],
|
||
)
|
||
return []
|
||
return best_children
|
||
|
||
|
||
def _mobilede_probe_segment_total(segment: dict[str, object]) -> int | None:
|
||
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
|
||
if not search_url:
|
||
return None
|
||
return _mobilede_probe_total(search_url)
|
||
|
||
|
||
def _mobilede_touch_planning_progress(stage: str = "runtime_segments_planning") -> None:
|
||
"""Refresh global progress while the expensive segment planner is probing mobile.de."""
|
||
try:
|
||
redis_client = _get_redis()
|
||
redis_client.set(GLOBAL_PROGRESS_TS_KEY, str(int(time.time())), ex=2 * 60 * 60)
|
||
except Exception:
|
||
logger.debug("Failed to touch mobile.de planning progress: stage=%s", stage, exc_info=True)
|
||
|
||
|
||
def _mobilede_segment_needs_preplan_split(segment: dict[str, object], total_results: int | None) -> bool:
|
||
if total_results is None:
|
||
return False
|
||
threshold = min(MOBILEDE_SEGMENT_TARGET_RESULTS, _mobilede_overflow_threshold(MOBILEDE_MAX_PAGE_NUMBER))
|
||
return int(total_results) > int(threshold * MOBILEDE_PREPLAN_SPLIT_THRESHOLD_RATIO)
|
||
|
||
|
||
def _mobilede_finalize_preplanned_segment(segment: dict[str, object], total_results: int | None) -> dict[str, object]:
|
||
item = dict(segment)
|
||
item["start_page"] = 1
|
||
item["max_pages"] = _mobilede_segment_pages_for_total(
|
||
total_results,
|
||
int(item.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER),
|
||
)
|
||
item["total_results"] = total_results
|
||
if total_results is not None:
|
||
label = str(item.get("label") or _mobilede_segment_key(item))
|
||
if "total=" not in label:
|
||
item["label"] = f"{label} | total={total_results}"
|
||
return item
|
||
|
||
|
||
def _mobilede_preplan_segment_tree(segment: dict[str, object], probe_budget: dict[str, int]) -> list[dict[str, object]]:
|
||
"""Probe and split a segment before dispatching any sync task.
|
||
|
||
The plan is intentionally hybrid: deterministic segments are built first,
|
||
then only a small global probe budget is used to catch obviously dense
|
||
ranges. This prevents a long recursive probe phase before cars start being
|
||
processed.
|
||
"""
|
||
pending: list[dict[str, object]] = [dict(segment)]
|
||
planned: list[dict[str, object]] = []
|
||
seen: set[str] = set()
|
||
|
||
while pending:
|
||
if probe_budget["used"] >= probe_budget["limit"]:
|
||
logger.warning(
|
||
"mobile.de preplan probe budget reached: segment=%s probes=%s limit=%s pending=%s planned=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
probe_budget["used"],
|
||
probe_budget["limit"],
|
||
len(pending),
|
||
len(planned),
|
||
)
|
||
planned.extend(_mobilede_finalize_preplanned_segment(item, None) for item in pending)
|
||
break
|
||
if len(planned) + len(pending) >= MOBILEDE_PREPLAN_MAX_SEGMENTS:
|
||
logger.warning(
|
||
"mobile.de preplan segment limit reached: segment=%s limit=%s pending=%s planned=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
MOBILEDE_PREPLAN_MAX_SEGMENTS,
|
||
len(pending),
|
||
len(planned),
|
||
)
|
||
planned.extend(_mobilede_finalize_preplanned_segment(item, None) for item in pending)
|
||
break
|
||
|
||
current = pending.pop(0)
|
||
fingerprint = _mobilede_segment_fingerprint(current)
|
||
if fingerprint in seen:
|
||
continue
|
||
seen.add(fingerprint)
|
||
|
||
total_results = _mobilede_probe_segment_total(current)
|
||
probe_budget["used"] += 1
|
||
if _mobilede_should_skip_planned_segment(total_results):
|
||
continue
|
||
|
||
depth = max(0, int(_mobilede_parse_optional_int(current.get("overflow_depth")) or 0))
|
||
max_preplan_depth = min(MOBILEDE_OVERFLOW_MAX_SPLIT_DEPTH, MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH)
|
||
if _mobilede_segment_needs_preplan_split(current, total_results) and depth < max_preplan_depth:
|
||
children = _mobilede_build_overflow_child_segments(
|
||
segment=current,
|
||
max_pages=int(current.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER),
|
||
probe_best_split=True,
|
||
)
|
||
new_children = [child for child in children if _mobilede_segment_fingerprint(child) not in seen]
|
||
if new_children:
|
||
logger.info(
|
||
"mobile.de preplan split: segment=%s total=%s children=%s depth=%s",
|
||
_mobilede_short_segment_label(current),
|
||
total_results,
|
||
len(new_children),
|
||
depth,
|
||
)
|
||
pending.extend(new_children)
|
||
continue
|
||
logger.warning(
|
||
"mobile.de preplan could not split dense segment: segment=%s total=%s depth=%s max_depth=%s",
|
||
_mobilede_short_segment_label(current),
|
||
total_results,
|
||
depth,
|
||
max_preplan_depth,
|
||
)
|
||
|
||
planned.append(_mobilede_finalize_preplanned_segment(current, total_results))
|
||
|
||
return planned
|
||
|
||
|
||
def _mobilede_preplan_runtime_segments(segments: list[dict[str, object]]) -> list[dict[str, object]]:
|
||
if not MOBILEDE_PREPLAN_SEGMENT_PROBES:
|
||
return segments
|
||
planned: list[dict[str, object]] = []
|
||
probe_budget = {"used": 0, "limit": MOBILEDE_PREPLAN_MAX_PROBES}
|
||
for index, segment in enumerate(segments):
|
||
if len(planned) >= MOBILEDE_PREPLAN_MAX_SEGMENTS:
|
||
remaining = segments[index:]
|
||
logger.warning(
|
||
"mobile.de global preplan segment limit reached: limit=%s planned=%s remaining=%s",
|
||
MOBILEDE_PREPLAN_MAX_SEGMENTS,
|
||
len(planned),
|
||
len(remaining),
|
||
)
|
||
planned.extend(_mobilede_finalize_preplanned_segment(item, None) for item in remaining)
|
||
break
|
||
if probe_budget["used"] >= probe_budget["limit"]:
|
||
remaining = segments[index:]
|
||
logger.warning(
|
||
"mobile.de preplan switches to no-probe mode: probes=%s limit=%s planned=%s remaining=%s",
|
||
probe_budget["used"],
|
||
probe_budget["limit"],
|
||
len(planned),
|
||
len(remaining),
|
||
)
|
||
planned.extend(_mobilede_finalize_preplanned_segment(item, None) for item in remaining)
|
||
break
|
||
planned.extend(_mobilede_preplan_segment_tree(segment, probe_budget))
|
||
if len(planned) > MOBILEDE_PREPLAN_MAX_SEGMENTS:
|
||
logger.warning(
|
||
"mobile.de global preplan segment list truncated: limit=%s planned_before_truncate=%s",
|
||
MOBILEDE_PREPLAN_MAX_SEGMENTS,
|
||
len(planned),
|
||
)
|
||
planned = planned[:MOBILEDE_PREPLAN_MAX_SEGMENTS]
|
||
break
|
||
logger.info(
|
||
"mobile.de preplanned final segment list: input=%s final=%s probes_used=%s probe_limit=%s",
|
||
len(segments),
|
||
len(planned),
|
||
probe_budget["used"],
|
||
probe_budget["limit"],
|
||
)
|
||
return planned
|
||
|
||
|
||
def _mobilede_try_expand_overflow_segment(
|
||
redis_client: Redis,
|
||
settings: Settings,
|
||
*,
|
||
segment: dict[str, object] | None,
|
||
listing_count: int,
|
||
unique_count: int,
|
||
max_pages: int,
|
||
segment_end_page: int,
|
||
) -> int:
|
||
if not MOBILEDE_OVERFLOW_SPLIT_ENABLED or not MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED or not segment:
|
||
return 0
|
||
|
||
normalized_max_pages = max(1, int(max_pages or segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER))
|
||
if int(segment_end_page) < normalized_max_pages:
|
||
return 0
|
||
|
||
observed = max(int(listing_count or 0), int(unique_count or 0))
|
||
threshold = _mobilede_overflow_threshold(normalized_max_pages)
|
||
if observed < threshold:
|
||
return 0
|
||
known_total = _mobilede_segment_total_results(segment)
|
||
page_cap = max(MOBILEDE_RESULTS_PER_PAGE, normalized_max_pages * MOBILEDE_RESULTS_PER_PAGE)
|
||
if known_total is not None and known_total <= page_cap:
|
||
logger.info(
|
||
"mobile.de overflow split skipped: segment=%s observed=%s threshold=%s total=%s cap=%s reason=known_total_within_cap",
|
||
_mobilede_short_segment_label(segment),
|
||
observed,
|
||
threshold,
|
||
known_total,
|
||
page_cap,
|
||
)
|
||
return 0
|
||
if known_total is None:
|
||
known_total = _mobilede_probe_segment_total(segment)
|
||
if known_total is not None and known_total <= page_cap:
|
||
logger.info(
|
||
"mobile.de overflow split skipped: segment=%s observed=%s threshold=%s total=%s cap=%s reason=probed_total_within_cap",
|
||
_mobilede_short_segment_label(segment),
|
||
observed,
|
||
threshold,
|
||
known_total,
|
||
page_cap,
|
||
)
|
||
return 0
|
||
|
||
child_segments = _mobilede_build_overflow_child_segments(
|
||
segment=segment,
|
||
max_pages=normalized_max_pages,
|
||
probe_best_split=True,
|
||
)
|
||
if not child_segments:
|
||
logger.warning(
|
||
"mobile.de overflow split skipped: segment=%s observed=%s threshold=%s depth=%s reason=no_child_segments",
|
||
_mobilede_short_segment_label(segment),
|
||
observed,
|
||
threshold,
|
||
int(_mobilede_parse_optional_int((segment or {}).get("overflow_depth")) or 0),
|
||
)
|
||
return 0
|
||
|
||
parent_fingerprint = _mobilede_segment_fingerprint(segment)
|
||
if int(redis_client.sadd(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, parent_fingerprint)) != 1:
|
||
return 0
|
||
redis_client.expire(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, 30 * 24 * 60 * 60)
|
||
|
||
lock_owner = f"overflow:{uuid.uuid4().hex}"
|
||
if not _acquire_lock(redis_client, MOBILEDE_RUNTIME_SEGMENTS_CACHE_LOCK_KEY, lock_owner, 120):
|
||
redis_client.srem(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, parent_fingerprint)
|
||
return 0
|
||
|
||
committed = False
|
||
try:
|
||
cached_segments = _get_cached_mobilede_runtime_segments(redis_client)
|
||
if cached_segments is None:
|
||
cached_segments = _build_mobilede_runtime_segments(settings)
|
||
|
||
existing_fingerprints = {
|
||
_mobilede_segment_fingerprint(item)
|
||
for item in cached_segments
|
||
if isinstance(item, dict)
|
||
}
|
||
appended_segments: list[dict[str, object]] = []
|
||
for child in child_segments:
|
||
child_fingerprint = _mobilede_segment_fingerprint(child)
|
||
if child_fingerprint in existing_fingerprints:
|
||
continue
|
||
existing_fingerprints.add(child_fingerprint)
|
||
appended_segments.append(child)
|
||
|
||
if not appended_segments:
|
||
committed = True
|
||
return 0
|
||
|
||
updated_segments = _mobilede_prune_overflow_parent_segments(
|
||
[dict(item) for item in cached_segments if isinstance(item, dict)] + appended_segments
|
||
)
|
||
redis_client.set(
|
||
MOBILEDE_RUNTIME_SEGMENTS_CACHE_KEY,
|
||
json.dumps(updated_segments, ensure_ascii=False),
|
||
ex=24 * 60 * 60,
|
||
)
|
||
_mobilede_save_learned_runtime_segments(
|
||
_mobilede_source_segments_from_settings(settings),
|
||
updated_segments,
|
||
)
|
||
redis_client.set(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY, str(len(updated_segments)))
|
||
done_segments = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY) or 0)
|
||
if done_segments < len(updated_segments):
|
||
redis_client.delete(MOBILEDE_BOOTSTRAP_DONE_KEY)
|
||
|
||
committed = True
|
||
logger.info(
|
||
"mobile.de overflow split appended: parent=%s parent_key=%s observed=%s threshold=%s added=%s total_segments=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
_mobilede_short_segment_ref(segment),
|
||
observed,
|
||
threshold,
|
||
len(appended_segments),
|
||
len(updated_segments),
|
||
)
|
||
return len(appended_segments)
|
||
except Exception:
|
||
logger.warning(
|
||
"Failed to append mobile.de overflow segments: parent=%s",
|
||
_mobilede_segment_label(segment),
|
||
exc_info=True,
|
||
)
|
||
return 0
|
||
finally:
|
||
if not committed:
|
||
try:
|
||
redis_client.srem(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, parent_fingerprint)
|
||
except Exception:
|
||
logger.debug("Failed to rollback overflow parent marker", exc_info=True)
|
||
_release_lock_if_owner(redis_client, MOBILEDE_RUNTIME_SEGMENTS_CACHE_LOCK_KEY, lock_owner)
|
||
|
||
|
||
def _mobilede_get_overflow_child_segments(
|
||
redis_client: Redis,
|
||
settings: Settings,
|
||
*,
|
||
parent_fingerprint: str,
|
||
) -> list[tuple[int, dict[str, object]]]:
|
||
cached_segments = _get_cached_mobilede_runtime_segments(redis_client)
|
||
if cached_segments is None:
|
||
cached_segments = _build_mobilede_runtime_segments(settings)
|
||
children: list[tuple[int, dict[str, object]]] = []
|
||
for index, item in enumerate(cached_segments):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if str(item.get("overflow_parent") or "").strip() != parent_fingerprint:
|
||
continue
|
||
children.append((index, dict(item)))
|
||
return children
|
||
|
||
|
||
def _queue_mobilede_overflow_child_segments(
|
||
redis_client: Redis,
|
||
settings: Settings,
|
||
*,
|
||
parent_segment: dict[str, object] | None,
|
||
lane: str,
|
||
delay_seconds: float,
|
||
use_cursor: bool,
|
||
only_new: bool | None,
|
||
bootstrap_run: bool,
|
||
refresh_cycle_id: str | None,
|
||
) -> int:
|
||
if not parent_segment:
|
||
return 0
|
||
parent_fingerprint = _mobilede_segment_fingerprint(parent_segment)
|
||
children = _mobilede_get_overflow_child_segments(
|
||
redis_client,
|
||
settings,
|
||
parent_fingerprint=parent_fingerprint,
|
||
)
|
||
if not children:
|
||
return 0
|
||
|
||
scope = refresh_cycle_id or ("bootstrap" if bootstrap_run else "runtime")
|
||
queue_key = MOBILEDE_OVERFLOW_CHILDREN_QUEUED_KEY_FMT.format(
|
||
scope=scope,
|
||
parent_key=parent_fingerprint,
|
||
)
|
||
ttl = max(3600, int(MOBILEDE_REFRESH_CYCLE_TTL_SECONDS))
|
||
queued = 0
|
||
for segment_index, child in children:
|
||
child_fingerprint = _mobilede_segment_fingerprint(child)
|
||
if int(redis_client.sadd(queue_key, child_fingerprint)) != 1:
|
||
continue
|
||
redis_client.expire(queue_key, ttl)
|
||
if bootstrap_run and only_new is not True:
|
||
_mobilede_try_mark_bootstrap_segment_dispatched(redis_client, child, ttl_seconds=ttl)
|
||
mobilede_sync_search_task.apply_async(
|
||
kwargs={
|
||
"start_page": int(child.get("start_page") or 1),
|
||
"max_pages": int(child.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER),
|
||
"lane": lane,
|
||
"only_new": only_new,
|
||
"search_url": str(child.get("search_url") or child.get("listing_url") or "").strip() or None,
|
||
"make_id": str(child.get("make_id") or "").strip() or None,
|
||
"model_id": str(child.get("model_id") or "").strip() or None,
|
||
"price_min": str(child.get("price_min") or "").strip() or None,
|
||
"price_max": str(child.get("price_max") or "").strip() or None,
|
||
"year_min": str(child.get("year_min") or "").strip() or None,
|
||
"year_max": str(child.get("year_max") or "").strip() or None,
|
||
"mileage_min": str(child.get("mileage_min") or "").strip() or None,
|
||
"mileage_max": str(child.get("mileage_max") or "").strip() or None,
|
||
"delay_seconds": delay_seconds,
|
||
"use_cursor": use_cursor,
|
||
"continuous": True,
|
||
"segment": child,
|
||
"segment_index": segment_index,
|
||
"runtime_rotation": False,
|
||
"bootstrap_run": bootstrap_run,
|
||
"refresh_cycle_id": refresh_cycle_id,
|
||
},
|
||
queue=MOBILEDE_SYNC_QUEUE,
|
||
countdown=0,
|
||
)
|
||
queued += 1
|
||
if queued:
|
||
logger.info(
|
||
"mobile.de overflow children queued immediately: parent=%s parent_key=%s queued=%s scope=%s",
|
||
_mobilede_short_segment_label(parent_segment),
|
||
_mobilede_short_segment_ref(parent_segment),
|
||
queued,
|
||
scope,
|
||
)
|
||
return queued
|
||
|
||
|
||
def _mobilede_probe_total(search_url: str, **params: str | int | None) -> int | None:
|
||
try:
|
||
client = MobileDeClient.for_worker(delay_seconds=0)
|
||
page = client.fetch_search_page(page_number=1, search_url=search_url, **params)
|
||
return int(page.total_results or 0)
|
||
except Exception as exc:
|
||
logger.warning("mobile.de segment probe failed: params=%s error=%s", params, exc)
|
||
return None
|
||
|
||
|
||
def _mobilede_segment_pages_for_total(total_results: int | None, fallback_max_pages: int) -> int:
|
||
if total_results is None or total_results <= 0:
|
||
return min(fallback_max_pages, MOBILEDE_MAX_PAGE_NUMBER)
|
||
pages = max(1, min(MOBILEDE_MAX_PAGE_NUMBER, (int(total_results) + MOBILEDE_RESULTS_PER_PAGE - 1) // MOBILEDE_RESULTS_PER_PAGE))
|
||
return min(fallback_max_pages, pages)
|
||
|
||
|
||
def _mobilede_make_expanded_segment(
|
||
base_segment: dict[str, object],
|
||
*,
|
||
search_url: str,
|
||
base_label: str,
|
||
label_parts: list[str],
|
||
params: dict[str, str | int | None],
|
||
total_results: int | None,
|
||
fallback_max_pages: int,
|
||
) -> dict[str, object]:
|
||
item = dict(base_segment)
|
||
item["search_url"] = _mobilede_make_segment_url(search_url, **params)
|
||
item["listing_url"] = item["search_url"]
|
||
item["start_page"] = 1
|
||
item["max_pages"] = _mobilede_segment_pages_for_total(total_results, fallback_max_pages)
|
||
item["total_results"] = total_results
|
||
total_label = str(total_results) if total_results is not None else "unknown"
|
||
item["label"] = f"{base_label} | {' | '.join(label_parts)} | total={total_label}"
|
||
return item
|
||
|
||
|
||
def _mobilede_set_segment_range_fields(
|
||
item: dict[str, object],
|
||
*,
|
||
price_min: int,
|
||
price_max: int | None,
|
||
year_range: tuple[int | None, int | None] | None = None,
|
||
mileage_range: tuple[int | None, int | None] | None = None,
|
||
) -> None:
|
||
item["price_min"] = str(price_min)
|
||
item["price_max"] = str(price_max) if price_max is not None else None
|
||
if year_range is not None:
|
||
year_min, year_max = year_range
|
||
item["year_min"] = str(year_min) if year_min is not None else None
|
||
item["year_max"] = str(year_max) if year_max is not None else None
|
||
if mileage_range is not None:
|
||
mileage_min, mileage_max = mileage_range
|
||
item["mileage_min"] = str(mileage_min) if mileage_min is not None else None
|
||
item["mileage_max"] = str(mileage_max) if mileage_max is not None else None
|
||
|
||
|
||
def _mobilede_make_probe_planned_segment(
|
||
base_segment: dict[str, object],
|
||
*,
|
||
search_url: str,
|
||
base_label: str,
|
||
label_parts: list[str],
|
||
params: dict[str, str | int | None],
|
||
total_results: int | None,
|
||
fallback_max_pages: int,
|
||
price_range: tuple[int, int | None] | None = None,
|
||
year_range: tuple[int | None, int | None] | None = None,
|
||
mileage_range: tuple[int | None, int | None] | None = None,
|
||
) -> dict[str, object]:
|
||
item = _mobilede_make_expanded_segment(
|
||
base_segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=label_parts,
|
||
params=params,
|
||
total_results=total_results,
|
||
fallback_max_pages=fallback_max_pages,
|
||
)
|
||
if price_range is not None:
|
||
_mobilede_set_segment_range_fields(item, price_min=price_range[0], price_max=price_range[1])
|
||
if year_range is not None:
|
||
price_min = int(item.get("price_min") or 1)
|
||
price_max_raw = str(item.get("price_max") or "").strip()
|
||
_mobilede_set_segment_range_fields(
|
||
item,
|
||
price_min=price_min,
|
||
price_max=int(price_max_raw) if price_max_raw else None,
|
||
year_range=year_range,
|
||
)
|
||
if mileage_range is not None:
|
||
price_min = int(item.get("price_min") or 1)
|
||
price_max_raw = str(item.get("price_max") or "").strip()
|
||
year_min_raw = str(item.get("year_min") or "").strip()
|
||
year_max_raw = str(item.get("year_max") or "").strip()
|
||
_mobilede_set_segment_range_fields(
|
||
item,
|
||
price_min=price_min,
|
||
price_max=int(price_max_raw) if price_max_raw else None,
|
||
year_range=(int(year_min_raw) if year_min_raw else None, int(year_max_raw) if year_max_raw else None),
|
||
mileage_range=mileage_range,
|
||
)
|
||
return item
|
||
|
||
|
||
def _mobilede_split_search_url_segment_by_make(segment: dict[str, object]) -> list[dict[str, object]]:
|
||
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
|
||
if not search_url:
|
||
return [segment]
|
||
|
||
make_tokens = _mobilede_url_query_values(search_url, "ms")
|
||
if len(make_tokens) <= 1:
|
||
return [segment]
|
||
|
||
base_label = str(segment.get("label") or "mobile.de filtered URL").strip() or "mobile.de filtered URL"
|
||
split_segments: list[dict[str, object]] = []
|
||
for make_token in make_tokens:
|
||
item = dict(segment)
|
||
item["search_url"] = _mobilede_make_segment_url(search_url, ms=make_token)
|
||
item["listing_url"] = item["search_url"]
|
||
item["source_search_url"] = str(segment.get("source_search_url") or search_url)
|
||
item["start_page"] = int(segment.get("start_page") or 1)
|
||
item["make_id"] = make_token
|
||
item["label"] = f"{base_label} | ms={make_token}"
|
||
split_segments.append(item)
|
||
|
||
logger.info(
|
||
"mobile.de split multi-make search URL into %s make segment(s): %s",
|
||
len(split_segments),
|
||
", ".join(str(item.get("label") or "") for item in split_segments),
|
||
)
|
||
return split_segments
|
||
|
||
|
||
def _expand_mobilede_search_url_segment_by_probe(segment: dict[str, object]) -> list[dict[str, object]] | None:
|
||
if not MOBILEDE_PREPLAN_SEGMENT_PROBES:
|
||
return None
|
||
|
||
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
|
||
if not search_url:
|
||
return None
|
||
|
||
base_label = str(segment.get("label") or "mobile.de segmented URL")
|
||
max_pages = int(segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER)
|
||
planned: list[dict[str, object]] = []
|
||
probes_used = 0
|
||
probe_limit = max(1, int(MOBILEDE_PREPLAN_MAX_PROBES))
|
||
|
||
def _probe(params: dict[str, str | int | None]) -> int | None:
|
||
nonlocal probes_used
|
||
if probes_used >= probe_limit:
|
||
return None
|
||
probes_used += 1
|
||
if probes_used == 1 or probes_used % 25 == 0:
|
||
_mobilede_touch_planning_progress("adaptive_url_planning")
|
||
if probes_used % 50 == 0:
|
||
logger.info(
|
||
"mobile.de adaptive planning progress: base=%s probes=%s/%s planned=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
probes_used,
|
||
probe_limit,
|
||
len(planned),
|
||
)
|
||
return _mobilede_probe_total(search_url, **params)
|
||
|
||
for price_min, price_max in _mobilede_price_ranges_for_segment(segment):
|
||
price_params: dict[str, str | int | None] = {"p": _mobilede_range_value(price_min, price_max)}
|
||
price_label = _mobilede_price_label(price_min, price_max)
|
||
price_total = _probe(price_params)
|
||
if _mobilede_should_skip_planned_segment(price_total):
|
||
continue
|
||
if price_total is None or price_total <= MOBILEDE_SEGMENT_TARGET_RESULTS:
|
||
planned.append(
|
||
_mobilede_make_probe_planned_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=[price_label],
|
||
params=price_params,
|
||
total_results=price_total,
|
||
fallback_max_pages=max_pages,
|
||
price_range=(price_min, price_max),
|
||
)
|
||
)
|
||
continue
|
||
|
||
for year_min, year_max in _mobilede_year_ranges_for_segment_price(segment, price_min, price_max):
|
||
year_label = _mobilede_year_label(year_min, year_max)
|
||
for refined_price_min, refined_price_max in _mobilede_price_subranges_for_hot_year(price_min, price_max, year_min, year_max):
|
||
refined_price_label = _mobilede_price_label(refined_price_min, refined_price_max)
|
||
year_params = {
|
||
"p": _mobilede_range_value(refined_price_min, refined_price_max),
|
||
"fr": _mobilede_range_value(year_min, year_max),
|
||
}
|
||
year_total = _probe(year_params)
|
||
if _mobilede_should_skip_planned_segment(year_total):
|
||
continue
|
||
if year_total is None or year_total <= MOBILEDE_SEGMENT_TARGET_RESULTS:
|
||
planned.append(
|
||
_mobilede_make_probe_planned_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=[refined_price_label, year_label],
|
||
params=year_params,
|
||
total_results=year_total,
|
||
fallback_max_pages=max_pages,
|
||
price_range=(refined_price_min, refined_price_max),
|
||
year_range=(year_min, year_max),
|
||
)
|
||
)
|
||
continue
|
||
|
||
for mileage_min, mileage_max in _mobilede_mileage_ranges():
|
||
mileage_params = dict(year_params)
|
||
mileage_params["ml"] = _mobilede_range_value(mileage_min, mileage_max)
|
||
mileage_label = _mobilede_mileage_label(mileage_min, mileage_max)
|
||
mileage_total = _probe(mileage_params)
|
||
if _mobilede_should_skip_planned_segment(mileage_total):
|
||
continue
|
||
planned.append(
|
||
_mobilede_make_probe_planned_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=[refined_price_label, year_label, mileage_label],
|
||
params=mileage_params,
|
||
total_results=mileage_total,
|
||
fallback_max_pages=max_pages,
|
||
price_range=(refined_price_min, refined_price_max),
|
||
year_range=(year_min, year_max),
|
||
mileage_range=(mileage_min, mileage_max),
|
||
)
|
||
)
|
||
|
||
logger.info(
|
||
"mobile.de adaptive URL segments planned: base=%s segments=%s probes=%s limit=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
len(planned),
|
||
probes_used,
|
||
probe_limit,
|
||
)
|
||
# Финальную dense-доразбивку делает _build_mobilede_runtime_segments.
|
||
# Здесь только возвращаем probe-план с total_results, чтобы не терять хвосты
|
||
# за mobile.de cap 50 страниц в сегментах >1000 результатов.
|
||
return [_mobilede_finalize_preplanned_segment(item, item.get("total_results")) for item in planned]
|
||
|
||
|
||
def _mobilede_refine_dense_planned_segments(segments: list[dict[str, object]]) -> list[dict[str, object]]:
|
||
threshold = min(MOBILEDE_SEGMENT_TARGET_RESULTS, _mobilede_overflow_threshold(MOBILEDE_MAX_PAGE_NUMBER))
|
||
pending: list[dict[str, object]] = [dict(item) for item in segments]
|
||
refined: list[dict[str, object]] = []
|
||
probes_used = 0
|
||
probe_limit = max(1, int(MOBILEDE_PREPLAN_MAX_PROBES))
|
||
max_refine_depth = max(MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH, MOBILEDE_OVERFLOW_MAX_SPLIT_DEPTH)
|
||
started_at = time.monotonic()
|
||
max_seconds = int(MOBILEDE_REFINE_ADAPTIVE_MAX_SECONDS)
|
||
|
||
while pending:
|
||
if max_seconds > 0 and time.monotonic() - started_at >= max_seconds:
|
||
logger.warning(
|
||
"mobile.de dense refine time budget reached: seconds=%s refined=%s pending=%s probes=%s/%s",
|
||
max_seconds,
|
||
len(refined),
|
||
len(pending),
|
||
probes_used,
|
||
probe_limit,
|
||
)
|
||
refined.extend(_mobilede_finalize_preplanned_segment(item, item.get("total_results")) for item in pending)
|
||
break
|
||
|
||
current = pending.pop(0)
|
||
total_results = current.get("total_results")
|
||
if total_results is None and probes_used < probe_limit:
|
||
total_results = _mobilede_probe_segment_total(current)
|
||
current["total_results"] = total_results
|
||
probes_used += 1
|
||
if probes_used == 1 or probes_used % 25 == 0:
|
||
_mobilede_touch_planning_progress("dense_refine")
|
||
if probes_used % 50 == 0:
|
||
logger.info(
|
||
"mobile.de dense refine progress: probes=%s/%s refined=%s pending=%s",
|
||
probes_used,
|
||
probe_limit,
|
||
len(refined),
|
||
len(pending),
|
||
)
|
||
|
||
depth = max(0, int(_mobilede_parse_optional_int(current.get("overflow_depth")) or 0))
|
||
if (
|
||
total_results is not None
|
||
and int(total_results) > threshold
|
||
and depth < max_refine_depth
|
||
and probes_used < probe_limit
|
||
and len(refined) + len(pending) < MOBILEDE_PREPLAN_MAX_SEGMENTS
|
||
):
|
||
children = _mobilede_build_overflow_child_segments(
|
||
segment=current,
|
||
max_pages=int(current.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER),
|
||
probe_best_split=True,
|
||
)
|
||
if children:
|
||
for child in children:
|
||
if probes_used >= probe_limit or len(refined) + len(pending) >= MOBILEDE_PREPLAN_MAX_SEGMENTS:
|
||
pending.append(child)
|
||
continue
|
||
child_total = _mobilede_probe_segment_total(child)
|
||
child["total_results"] = child_total
|
||
probes_used += 1
|
||
if probes_used == 1 or probes_used % 25 == 0:
|
||
_mobilede_touch_planning_progress("dense_refine")
|
||
if probes_used % 50 == 0:
|
||
logger.info(
|
||
"mobile.de dense refine progress: probes=%s/%s refined=%s pending=%s",
|
||
probes_used,
|
||
probe_limit,
|
||
len(refined),
|
||
len(pending),
|
||
)
|
||
if _mobilede_should_skip_planned_segment(child_total):
|
||
continue
|
||
pending.append(_mobilede_finalize_preplanned_segment(child, child_total))
|
||
continue
|
||
|
||
if total_results is not None and int(total_results) > threshold:
|
||
logger.warning(
|
||
"mobile.de dense segment remains after refine: segment=%s total=%s depth=%s max_depth=%s probes=%s/%s",
|
||
_mobilede_short_segment_label(current),
|
||
total_results,
|
||
depth,
|
||
max_refine_depth,
|
||
probes_used,
|
||
probe_limit,
|
||
)
|
||
|
||
refined.append(_mobilede_finalize_preplanned_segment(current, int(total_results) if total_results is not None else None))
|
||
|
||
logger.info(
|
||
"mobile.de dense planned segments refined: input=%s output=%s probes=%s threshold=%s",
|
||
len(segments),
|
||
len(refined),
|
||
probes_used,
|
||
threshold,
|
||
)
|
||
return refined
|
||
|
||
|
||
def _mobilede_should_refine_adaptive_segments() -> bool:
|
||
return os.getenv("MOBILEDE_REFINE_ADAPTIVE_SEGMENTS", "true").strip().lower() in {"1", "true", "yes", "on"}
|
||
|
||
|
||
def _mobilede_try_keep_root_segment_unsplit(
|
||
segment: dict[str, object],
|
||
*,
|
||
search_url: str,
|
||
max_pages: int,
|
||
) -> list[dict[str, object]] | None:
|
||
if not MOBILEDE_RUNTIME_BRAND_ROOT_PROBES_ENABLED:
|
||
return None
|
||
|
||
query_keys = {key for key, _ in parse_qsl(urlsplit(search_url).query, keep_blank_values=True)}
|
||
if query_keys & {"p", "fr", "ml"}:
|
||
return None
|
||
|
||
total_results = _mobilede_probe_total(search_url)
|
||
if _mobilede_should_skip_dynamic_segment(total_results):
|
||
logger.info("mobile.de root segment skipped as empty: segment=%s", _mobilede_short_segment_label(segment))
|
||
return []
|
||
if total_results is None:
|
||
return None
|
||
|
||
threshold = min(MOBILEDE_SEGMENT_TARGET_RESULTS, _mobilede_overflow_threshold(max_pages))
|
||
if int(total_results) > threshold:
|
||
logger.info(
|
||
"mobile.de root segment requires split: segment=%s total=%s threshold=%s",
|
||
_mobilede_short_segment_label(segment),
|
||
total_results,
|
||
threshold,
|
||
)
|
||
return None
|
||
|
||
item = _mobilede_finalize_preplanned_segment(segment, total_results)
|
||
logger.info(
|
||
"mobile.de root segment kept unsplit: segment=%s total=%s max_pages=%s",
|
||
_mobilede_short_segment_label(item),
|
||
total_results,
|
||
item.get("max_pages"),
|
||
)
|
||
return [item]
|
||
|
||
|
||
def _expand_mobilede_search_url_segment(
|
||
segment: dict[str, object],
|
||
*,
|
||
allow_adaptive_planning: bool = True,
|
||
allow_probe_planning: bool = True,
|
||
) -> list[dict[str, object]]:
|
||
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
|
||
if not search_url:
|
||
return [segment]
|
||
|
||
split_by_make = _mobilede_split_search_url_segment_by_make(segment)
|
||
if len(split_by_make) > 1:
|
||
expanded: list[dict[str, object]] = []
|
||
for split_segment in split_by_make:
|
||
expanded.extend(
|
||
_expand_mobilede_search_url_segment(
|
||
split_segment,
|
||
allow_adaptive_planning=allow_adaptive_planning,
|
||
allow_probe_planning=allow_probe_planning,
|
||
)
|
||
)
|
||
return expanded
|
||
|
||
# Готовый URL остаётся главным источником фильтров. Не режем его повторно
|
||
# только если пользователь уже задал price/year/mileage в самой ссылке.
|
||
# Остальные фильтры из URL (марка, тип кузова, топливо, страна и т.д.)
|
||
# должны сохраниться, а поверх них можно добавить p/fr/ml для обхода 50-page cap.
|
||
existing_range_keys = {"p", "fr", "ml"}
|
||
query_keys = {key for key, _ in parse_qsl(urlsplit(search_url).query, keep_blank_values=True)}
|
||
if query_keys & existing_range_keys:
|
||
return [segment]
|
||
|
||
max_pages = int(segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER)
|
||
root_segment = _mobilede_try_keep_root_segment_unsplit(
|
||
segment,
|
||
search_url=search_url,
|
||
max_pages=max_pages,
|
||
)
|
||
if root_segment is not None:
|
||
return root_segment
|
||
|
||
if not allow_adaptive_planning:
|
||
logger.info(
|
||
"mobile.de URL segment fast-start enabled: using search_url as-is for %s",
|
||
_mobilede_short_segment_label(segment),
|
||
)
|
||
return [segment]
|
||
|
||
if allow_probe_planning:
|
||
probe_planned = _expand_mobilede_search_url_segment_by_probe(segment)
|
||
if probe_planned is not None:
|
||
return probe_planned
|
||
|
||
expanded: list[dict[str, object]] = []
|
||
base_label = str(segment.get("label") or "mobile.de segmented URL")
|
||
for price_min, price_max in _mobilede_price_ranges_for_segment(segment):
|
||
params: dict[str, str | int | None] = {}
|
||
if price_max is not None:
|
||
params["p"] = f"{price_min}:{price_max}"
|
||
else:
|
||
params["p"] = f"{price_min}:"
|
||
price_label = _mobilede_price_label(price_min, price_max)
|
||
price_total = _mobilede_probe_total(search_url, **params) if MOBILEDE_DYNAMIC_SEGMENT_PROBES else None
|
||
if _mobilede_should_skip_dynamic_segment(price_total):
|
||
continue
|
||
if price_total is not None and price_total <= MOBILEDE_SEGMENT_TARGET_RESULTS:
|
||
item = _mobilede_make_expanded_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=[price_label],
|
||
params=params,
|
||
total_results=price_total,
|
||
fallback_max_pages=max_pages,
|
||
)
|
||
_mobilede_set_segment_range_fields(item, price_min=price_min, price_max=price_max)
|
||
expanded.append(item)
|
||
continue
|
||
|
||
for year_min, year_max in _mobilede_year_ranges_for_segment_price(segment, price_min, price_max):
|
||
year_label = _mobilede_year_label(year_min, year_max)
|
||
refined_price_ranges = _mobilede_price_subranges_for_hot_year(price_min, price_max, year_min, year_max)
|
||
for refined_price_min, refined_price_max in refined_price_ranges:
|
||
refined_price_label = _mobilede_price_label(refined_price_min, refined_price_max)
|
||
year_params = dict(params)
|
||
year_params["p"] = f"{refined_price_min}:{refined_price_max or ''}"
|
||
year_params["fr"] = _mobilede_range_value(year_min, year_max)
|
||
year_total = _mobilede_probe_total(search_url, **year_params) if MOBILEDE_DYNAMIC_SEGMENT_PROBES else None
|
||
if _mobilede_should_skip_dynamic_segment(year_total):
|
||
continue
|
||
if year_total is not None and year_total <= MOBILEDE_SEGMENT_TARGET_RESULTS:
|
||
item = _mobilede_make_expanded_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=[refined_price_label, year_label],
|
||
params=year_params,
|
||
total_results=year_total,
|
||
fallback_max_pages=max_pages,
|
||
)
|
||
_mobilede_set_segment_range_fields(
|
||
item,
|
||
price_min=refined_price_min,
|
||
price_max=refined_price_max,
|
||
year_range=(year_min, year_max),
|
||
)
|
||
expanded.append(item)
|
||
continue
|
||
|
||
if (
|
||
not MOBILEDE_DYNAMIC_SEGMENT_PROBES
|
||
and not MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE
|
||
and not _mobilede_should_pre_split_mileage(refined_price_min, refined_price_max, year_min, year_max)
|
||
):
|
||
item = _mobilede_make_expanded_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=[refined_price_label, year_label],
|
||
params=year_params,
|
||
total_results=year_total,
|
||
fallback_max_pages=max_pages,
|
||
)
|
||
_mobilede_set_segment_range_fields(
|
||
item,
|
||
price_min=refined_price_min,
|
||
price_max=refined_price_max,
|
||
year_range=(year_min, year_max),
|
||
)
|
||
expanded.append(item)
|
||
continue
|
||
|
||
for mileage_min, mileage_max in _mobilede_mileage_ranges():
|
||
mileage_params = dict(year_params)
|
||
mileage_params["ml"] = _mobilede_range_value(mileage_min, mileage_max)
|
||
mileage_label = _mobilede_mileage_label(mileage_min, mileage_max)
|
||
mileage_total = _mobilede_probe_total(search_url, **mileage_params) if MOBILEDE_DYNAMIC_SEGMENT_PROBES else None
|
||
if _mobilede_should_skip_dynamic_segment(mileage_total):
|
||
continue
|
||
item = _mobilede_make_expanded_segment(
|
||
segment,
|
||
search_url=search_url,
|
||
base_label=base_label,
|
||
label_parts=[refined_price_label, year_label, mileage_label],
|
||
params=mileage_params,
|
||
total_results=mileage_total,
|
||
fallback_max_pages=max_pages,
|
||
)
|
||
_mobilede_set_segment_range_fields(
|
||
item,
|
||
price_min=refined_price_min,
|
||
price_max=refined_price_max,
|
||
year_range=(year_min, year_max),
|
||
mileage_range=(mileage_min, mileage_max),
|
||
)
|
||
expanded.append(item)
|
||
return expanded
|
||
|
||
|
||
def _build_mobilede_runtime_segments(settings: Settings) -> list[dict[str, object]]:
|
||
env_search_urls = settings.listing.filtered_search_urls
|
||
# Ready-made filtered URLs default to the old deterministic price/year split:
|
||
# it starts immediately and is easier to keep stable. Probe-based adaptive
|
||
# planning can still be enabled explicitly when we need denser pre-plans.
|
||
fast_start_filtered_urls = (
|
||
bool(env_search_urls)
|
||
and os.getenv("MOBILEDE_FILTERED_URL_FAST_START", "false").strip().lower() in {"1", "true", "yes", "on"}
|
||
)
|
||
adaptive_filtered_urls = (
|
||
bool(env_search_urls)
|
||
and os.getenv("MOBILEDE_FILTERED_URL_ADAPTIVE_PLAN", "false").strip().lower() in {"1", "true", "yes", "on"}
|
||
)
|
||
if env_search_urls:
|
||
segments = _mobilede_source_segments_from_settings(settings)
|
||
if any(str(item.get("runtime_brand") or "").strip() for item in segments):
|
||
logger.info(
|
||
"mobile.de runtime segments source: runtime brands via env filtered_search_urls templates=%s segments=%s",
|
||
len(env_search_urls),
|
||
len(segments),
|
||
)
|
||
else:
|
||
logger.info("mobile.de runtime segments source: env filtered_search_urls count=%s", len(env_search_urls))
|
||
else:
|
||
segments = _mobilede_source_segments_from_settings(settings)
|
||
learned_segments = _mobilede_load_learned_runtime_segments(segments)
|
||
if learned_segments is not None:
|
||
return learned_segments
|
||
expanded: list[dict[str, object]] = []
|
||
expanded_from_adaptive_url = False
|
||
for segment in segments:
|
||
if segment.get("auto_segment") is False:
|
||
expanded.append(segment)
|
||
continue
|
||
segment_expanded = _expand_mobilede_search_url_segment(
|
||
segment,
|
||
allow_adaptive_planning=not fast_start_filtered_urls,
|
||
allow_probe_planning=not env_search_urls or adaptive_filtered_urls,
|
||
)
|
||
expanded.extend(segment_expanded)
|
||
if any(item.get("total_results") is not None for item in segment_expanded):
|
||
expanded_from_adaptive_url = True
|
||
if len(expanded) != len(segments):
|
||
logger.info("mobile.de segments planned: input=%s total=%s", len(segments), len(expanded))
|
||
if fast_start_filtered_urls:
|
||
logger.info(
|
||
"mobile.de fast-start runtime segments ready: input_urls=%s final=%s reason=filtered_search_urls",
|
||
len(env_search_urls),
|
||
len(expanded),
|
||
)
|
||
return [_mobilede_finalize_preplanned_segment(item, item.get("total_results")) for item in expanded]
|
||
if expanded_from_adaptive_url:
|
||
if _mobilede_should_refine_adaptive_segments():
|
||
refined = _mobilede_refine_dense_planned_segments(expanded)
|
||
logger.info(
|
||
"mobile.de adaptive URL plan refined: before=%s after=%s reason=dense_segments",
|
||
len(expanded),
|
||
len(refined),
|
||
)
|
||
return [_mobilede_finalize_preplanned_segment(item, item.get("total_results")) for item in refined]
|
||
logger.info(
|
||
"mobile.de preplan skipped after adaptive URL planning: final=%s reason=fast_start",
|
||
len(expanded),
|
||
)
|
||
return [_mobilede_finalize_preplanned_segment(item, item.get("total_results")) for item in expanded]
|
||
return _mobilede_preplan_runtime_segments(expanded)
|