Merge remote-tracking branch 'forgejo/main' into fix/3034-avito-fingerprint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m34s

This commit is contained in:
bot-backend 2026-08-21 23:34:34 +03:00
commit b3a39699d1
16 changed files with 1404 additions and 24 deletions

205
ops/backup-forgejo.sh Executable file
View file

@ -0,0 +1,205 @@
#!/usr/bin/env bash
# Backup for self-hosted Forgejo (git.gendsgn.ru) — repos + DB, off-box to S3
# under its OWN, more-restricted key (#2203).
#
# Forgejo is deployed OUTSIDE this repo's checkout, per
# infra/Forgejo_Migration_BotServer_To_Beget_2026-05-16.md (vault):
# - compose dir on the VM: /home/gendesign/forgejo/
# - repo data: /home/gendesign/forgejo/data/forgejo/git/repositories/
# - DB: NOT a dedicated container — a separate `forgejo` user+database
# inside the SAME shared postgres container as the main app
# (gendesign-postgres-1). `pg_dump -U forgejo forgejo` via `docker exec`.
#
# Two artifacts per run:
# 1. DB dump — `pg_dump --no-owner --clean --if-exists` (repos/issues/PRs/
# users/settings/Actions config — everything except the git data itself).
# 2. Repo bundle — a tar.gz of the bare-repo tree under FORGEJO_REPOS_DIR.
# This is functionally a "bundle of repositories": bare repos already
# ARE just refs+objects, byte-identical to what `git bundle` would
# capture, and tar-ing the whole tree in one shot is far more robust
# than enumerating individual repos (new repos need zero script changes,
# nothing to keep in sync). LFS objects/attachments/avatars under
# FORGEJO_DATA_DIR are OUT of scope for this pass — repo code + full DB
# (which has LFS pointers, not the blobs) covers the "backup the code"
# ask; note this gap in the PR if larger asset backup is wanted later.
#
# Off-box S3 upload is MANDATORY here, not optional like ops/backup.sh —
# the whole point of this script is getting Forgejo's code off the VM it
# lives on, so with no S3 creds configured this refuses to run rather than
# silently producing a local-only "backup" that doesn't meet that bar.
#
# The S3 key here is a SEPARATE, narrower-scoped service user than the one
# ops/backup.sh and backup-tradein-db.sh use (`gendsgn-backup-writer`, which
# can write anywhere in the bucket root) — this one can ONLY PutObject under
# s3://gendsgn-backups/forgejo/*, nothing else, so a compromised Forgejo host
# can't touch the main/tradein DB backups sitting in the same bucket. See the
# PR description for the exact bucket policy JSON (mirrors the existing
# gendsgn-backup-writer policy pattern — vault meta/00_credentials.md
# "Selectel S3 — бэкап-бакет gendsgn-backups").
#
# THE KEY DOES NOT EXIST YET (as of #2203) — a human needs to create the
# service user + policy in the Selectel panel first. Until then this script
# is expected to fail loudly and exit non-zero; that is the correct,
# intentional behaviour, not a bug.
#
# Missed-run detection (#2203): same sentinel/staleness-check pattern as
# ops/backup.sh — see ops/check-backup-staleness.sh for the alerting side.
#
# Usage (cron — `bash <path>`, not a bare path, same "+x can go missing on a
# raw deploy reset" reasoning as the other two backup scripts, #71):
# 15 4 * * * bash /opt/gendesign/ops/backup-forgejo.sh >> /var/log/gendesign-backup-forgejo.log 2>&1
#
# Config file (root-owned, chmod 600, NOT in git) — see
# ops/gendesign-backup-forgejo.default.example for the full list:
# FORGEJO_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
# FORGEJO_S3_BUCKET=gendsgn-backups
# FORGEJO_S3_PREFIX=forgejo/
# FORGEJO_S3_ACCESS_KEY=...
# FORGEJO_S3_SECRET_KEY=...
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib-backup.sh
source "$SCRIPT_DIR/lib-backup.sh"
# --- config (env-overridable) ---
FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}"
FORGEJO_DATA_DIR="${FORGEJO_DATA_DIR:-${FORGEJO_DIR}/data/forgejo}"
FORGEJO_REPOS_DIR="${FORGEJO_REPOS_DIR:-${FORGEJO_DATA_DIR}/git/repositories}"
PG_CONTAINER="${PG_CONTAINER:-gendesign-postgres-1}"
FORGEJO_DB_USER="${FORGEJO_DB_USER:-forgejo}"
FORGEJO_DB_NAME="${FORGEJO_DB_NAME:-forgejo}"
LOCAL_BACKUP_DIR="${LOCAL_BACKUP_DIR:-/opt/gendesign/backups/forgejo}"
KEEP="${KEEP:-7}"
MIN_DB_DUMP_BYTES="${MIN_DB_DUMP_BYTES:-2048}" # forgejo DB is small (config/issues/PRs, no git blobs)
MIN_REPOS_BUNDLE_BYTES="${MIN_REPOS_BUNDLE_BYTES:-10240}"
SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}"
FORGEJO_BACKUP_ENV_FILE="${FORGEJO_BACKUP_ENV_FILE:-/etc/default/gendesign-backup-forgejo}"
# shellcheck source=/dev/null
[[ -f "$FORGEJO_BACKUP_ENV_FILE" ]] && source "$FORGEJO_BACKUP_ENV_FILE"
FORGEJO_S3_ENDPOINT="${FORGEJO_S3_ENDPOINT:-}"
FORGEJO_S3_BUCKET="${FORGEJO_S3_BUCKET:-}"
FORGEJO_S3_PREFIX="${FORGEJO_S3_PREFIX:-forgejo/}"
FORGEJO_S3_ACCESS_KEY="${FORGEJO_S3_ACCESS_KEY:-}"
FORGEJO_S3_SECRET_KEY="${FORGEJO_S3_SECRET_KEY:-}"
# --- guard 0: S3 creds MUST be configured — this backup's whole point is
# off-box storage under a narrow-scoped key. No creds = refuse to run,
# loudly, rather than silently producing a local-only "backup" (unlike
# ops/backup.sh, where local-only is an accepted fallback). ---
if [[ -z "$FORGEJO_S3_ENDPOINT" || -z "$FORGEJO_S3_BUCKET" || -z "$FORGEJO_S3_ACCESS_KEY" || -z "$FORGEJO_S3_SECRET_KEY" ]]; then
log "ERROR: Forgejo S3 backup is NOT CONFIGURED."
log "ERROR: missing one or more of FORGEJO_S3_ENDPOINT / FORGEJO_S3_BUCKET / FORGEJO_S3_ACCESS_KEY / FORGEJO_S3_SECRET_KEY."
log "ERROR: create the narrow-scoped 'gendsgn-backup-forgejo-writer' S3 service user (see PR for the exact bucket policy)"
log "ERROR: and fill them into ${FORGEJO_BACKUP_ENV_FILE} (see ops/gendesign-backup-forgejo.default.example)."
log "ERROR: refusing to produce a local-only backup — that does not meet this script's off-box requirement."
exit 1
fi
if [[ ! -d "$FORGEJO_REPOS_DIR" ]]; then
log "ERROR: FORGEJO_REPOS_DIR does not exist -> $FORGEJO_REPOS_DIR"
log "ERROR: Forgejo's on-disk layout may differ from the documented default — override FORGEJO_REPOS_DIR."
exit 1
fi
mkdir -p "$LOCAL_BACKUP_DIR"
ts=$(date -u +'%Y%m%d_%H%M%S')
db_out="${LOCAL_BACKUP_DIR}/forgejo-db_${ts}.sql.gz"
repos_out="${LOCAL_BACKUP_DIR}/forgejo-repos_${ts}.tar.gz"
# --- 1. DB dump ---
log "Dumping Forgejo DB (${FORGEJO_DB_NAME} as ${FORGEJO_DB_USER}, container ${PG_CONTAINER}) -> ${db_out}"
docker exec "$PG_CONTAINER" pg_dump -U "$FORGEJO_DB_USER" -d "$FORGEJO_DB_NAME" --no-owner --clean --if-exists \
| gzip -9 > "$db_out"
if [[ ! -s "$db_out" ]]; then
log "ERROR: DB dump is empty -> $db_out — removing, keeping previous good backups." >&2
rm -f "$db_out"
exit 1
fi
db_bytes=$(wc -c < "$db_out" | tr -d ' ')
if (( db_bytes < MIN_DB_DUMP_BYTES )); then
log "ERROR: DB dump only ${db_bytes} bytes (< ${MIN_DB_DUMP_BYTES} floor) — likely a failed dump." >&2
rm -f "$db_out"
exit 1
fi
if ! verify_dump_integrity "$db_out" "-- PostgreSQL database dump complete" "forgejo DB dump"; then
log "Removing suspect DB dump, NOT pruning older good backups." >&2
rm -f "$db_out"
exit 1
fi
log "DB dump OK: ${db_out} ($(du -h "$db_out" | cut -f1), ${db_bytes} bytes)"
# --- 2. repo bundle (tar.gz of the bare-repo tree) ---
log "Bundling Forgejo repos from ${FORGEJO_REPOS_DIR} -> ${repos_out}"
tar -czf "$repos_out" -C "$(dirname "$FORGEJO_REPOS_DIR")" "$(basename "$FORGEJO_REPOS_DIR")"
if [[ ! -s "$repos_out" ]]; then
log "ERROR: repo bundle is empty -> $repos_out — removing." >&2
rm -f "$repos_out"
exit 1
fi
repos_bytes=$(wc -c < "$repos_out" | tr -d ' ')
if (( repos_bytes < MIN_REPOS_BUNDLE_BYTES )); then
log "ERROR: repo bundle only ${repos_bytes} bytes (< ${MIN_REPOS_BUNDLE_BYTES} floor) — likely empty/failed FORGEJO_REPOS_DIR." >&2
rm -f "$repos_out"
exit 1
fi
# tar integrity: a bundle truncated mid-write (disk full, killed process)
# fails `tar -t` even when the gzip framing looks superficially OK — same
# "don't trust non-empty + big-enough alone" discipline as verify_dump_integrity.
if ! tar -tzf "$repos_out" >/dev/null 2>&1; then
log "ERROR: repo bundle failed tar integrity check -> $repos_out (truncated archive?)" >&2
rm -f "$repos_out"
exit 1
fi
log "Repo bundle OK: ${repos_out} ($(du -h "$repos_out" | cut -f1), ${repos_bytes} bytes)"
# --- 3. upload to S3 (mandatory — see guard 0 above) ---
for f in "$db_out" "$repos_out"; do
key="${FORGEJO_S3_PREFIX%/}/$(basename "$f")"
log "Uploading to s3://${FORGEJO_S3_BUCKET}/${key}"
# Same AWS_CA_BUNDLE fix as ops/backup.sh / backup-tradein-db.sh — aws-cli
# v2's own baked-in CA bundle is missing the root Selectel's cert chains
# to; point it at the container's system store instead.
docker run --rm \
-e AWS_ACCESS_KEY_ID="$FORGEJO_S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$FORGEJO_S3_SECRET_KEY" \
-e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
-v "$LOCAL_BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$FORGEJO_S3_ENDPOINT" \
s3 cp --no-progress "/backup/$(basename "$f")" "s3://${FORGEJO_S3_BUCKET}/${key}"
done
log "S3 upload OK"
# --- 4. local retention ---
# shellcheck disable=SC2012
ls -1t "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
# shellcheck disable=SC2012
ls -1t "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
shopt -s nullglob
remaining_db=( "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz )
remaining_repos=( "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz )
shopt -u nullglob
log "Forgejo backup done. Local copies retained: ${#remaining_db[@]} db + ${#remaining_repos[@]} repo bundles (KEEP=${KEEP})."
# --- missed-run detection (#2203): mark success ONLY after everything above
# (including the mandatory S3 upload) has passed. ---
write_sentinel "$SENTINEL_FILE"

View file

@ -34,9 +34,19 @@
#
# Restore: see ops/restore.sh (destructive, INTO the live DB).
# Restore drill (safe, throwaway container): see ops/restore-drill.sh.
#
# Missed-run detection (#2203): on a verified-good run this writes a sentinel
# file (SENTINEL_FILE) with the UTC timestamp. A SEPARATE cron entry running
# ops/check-backup-staleness.sh against that file is what actually alerts if
# cron stops firing — see that script's header for the alert channel and
# cron line. This script itself does NOT alert; it only ever marks success.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib-backup.sh
source "$SCRIPT_DIR/lib-backup.sh"
# --- config (env-overridable) ---
COMPOSE_DIR="${COMPOSE_DIR:-/opt/gendesign}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
@ -49,12 +59,11 @@ MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-51200}" # 50 KiB floor; gzip'd schema-only du
# (No equivalent floor for the globals
# dump — a handful of roles legitimately
# gzips to well under this.)
SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}"
# Optional S3 env. Loaded from /etc/default/gendesign-backup if present.
[[ -f /etc/default/gendesign-backup ]] && source /etc/default/gendesign-backup
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# Integrity check beyond "non-empty"/"big enough": a dump truncated mid-write
# (disk full, OOM-kill, docker exec dropped) can still gzip into a structurally
# valid, non-tiny .gz — neither `-s` nor MIN_DUMP_BYTES catch that reliably.
@ -198,3 +207,10 @@ remaining=( "$LOCAL_BACKUP_DIR"/gendesign_[0-9]*.sql.gz )
remaining_globals=( "$LOCAL_BACKUP_DIR"/gendesign_globals_*.sql.gz )
shopt -u nullglob
log "Backup done. Local dumps retained: ${#remaining[@]} data + ${#remaining_globals[@]} globals (KEEP=${KEEP})."
# --- missed-run detection (#2203): mark success ONLY here, after every
# guard above (dump/globals integrity, retention, and — since this whole
# script runs under `set -euo pipefail` — any S3 upload) has passed. See
# ops/check-backup-staleness.sh for the piece that actually alerts if
# this stops being refreshed. ---
write_sentinel "$SENTINEL_FILE"

84
ops/check-backup-staleness.sh Executable file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Missed-run detector for the backup scripts (#2203).
#
# ops/backup.sh, ops/backup-forgejo.sh, and tradein-mvp/deploy/backup-tradein-db.sh
# each write a sentinel file (via write_sentinel() in ops/lib-backup.sh) the
# instant a run finishes with a VERIFIED-good dump — same file the older two
# scripts already treat as "only mark success once every guard passed".
# This script checks how old that sentinel is. If cron stops firing (or every
# run fails before reaching the sentinel write), the file goes stale and this
# script says so loudly instead of the silence that let backups break for
# weeks undetected before (#71).
#
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
# Telegram once a backup is already known to be stale.
#
# Usage (cron — one line per sentinel, run more often than the backup itself
# so a stale state is caught promptly; hourly is a reasonable default for a
# daily-cron backup with a 26h threshold):
# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \
# /opt/gendesign/backups/.last_success 26 "gendesign main backup" \
# >> /var/log/gendesign-backup-staleness.log 2>&1
# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \
# /opt/gendesign/backups/tradein/.last_success 26 "tradein backup" \
# >> /var/log/gendesign-backup-staleness.log 2>&1
# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
# >> /var/log/gendesign-backup-staleness.log 2>&1
#
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
#
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
# used as a plain healthcheck by anything that just wants the exit code).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib-backup.sh
source "$SCRIPT_DIR/lib-backup.sh"
usage() {
echo "Usage: $(basename "$0") <sentinel-file> [max-age-hours=26] [label=backup]" >&2
exit 2
}
[[ $# -ge 1 ]] || usage
sentinel_file="$1"
max_age_hours="${2:-26}"
label="${3:-backup}"
state_file="${BACKUP_STALENESS_STATE_FILE:-/var/tmp/gendesign-backup-staleness-state}"
age_hours="$(sentinel_age_hours "$sentinel_file" 2>/dev/null || true)"
if [[ -z "$age_hours" ]]; then
now_status="stale"
log "ERROR: sentinel missing/unreadable -> $sentinel_file (${label} has never completed successfully, or its state was lost)."
else
if (( age_hours >= max_age_hours )); then
now_status="stale"
log "ERROR: ${label} sentinel is ${age_hours}h old (threshold ${max_age_hours}h) -> $sentinel_file"
else
now_status="fresh"
log "OK: ${label} sentinel is ${age_hours}h old (< ${max_age_hours}h threshold)."
fi
fi
prev_status="$(backup_prev_status "$state_file" "$label")"
if [[ "$now_status" == "stale" ]]; then
if [[ "$prev_status" != "stale" ]]; then
notify "🔴 ${label}: backup sentinel is stale (missing or older than ${max_age_hours}h) -> ${sentinel_file}. Check cron / backup logs on the VM."
fi
else
if [[ "$prev_status" == "stale" ]]; then
notify "${label}: backup sentinel is fresh again -> ${sentinel_file}."
fi
fi
backup_set_status "$state_file" "$label" "$now_status"
[[ "$now_status" == "fresh" ]]

View file

@ -0,0 +1,44 @@
# Environment file for ops/backup-forgejo.sh (Forgejo code + DB backup, #2203).
#
# Install on the prod VM as a ROOT-OWNED, chmod-600 file that is NOT in git:
# sudo cp /opt/gendesign/ops/gendesign-backup-forgejo.default.example /etc/default/gendesign-backup-forgejo
# sudo chmod 600 /etc/default/gendesign-backup-forgejo
# sudo $EDITOR /etc/default/gendesign-backup-forgejo # fill in real S3 credentials
#
# DELIBERATELY a separate file from /etc/default/gendesign-backup (used by
# ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh) — this backup
# uses a SEPARATE, narrower-scoped S3 service user that can ONLY PutObject
# under s3://gendsgn-backups/forgejo/*, nothing else. The prod-host writer
# key used by the other two backups (`gendsgn-backup-writer`) has root-of-
# bucket access; that key must NOT be reused here, and this key must NOT be
# put in /etc/default/gendesign-backup.
#
# THE KEY DOES NOT EXIST YET (as of #2203). Until a human creates the
# `gendsgn-backup-forgejo-writer` service user + bucket policy in the
# Selectel panel (see the #2203 PR description for the exact policy JSON —
# PutObject-only on arn:aws:s3:::gendsgn-backups/forgejo/*, explicit Deny on
# GetObject/ListBucket/Delete*) and fills in the four FORGEJO_S3_* vars
# below, ops/backup-forgejo.sh refuses to run and exits non-zero loudly. That
# is expected, not a bug.
#
# --- S3 off-site upload (Selectel S3-compatible). All four required. ---
#FORGEJO_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
#FORGEJO_S3_BUCKET=gendsgn-backups
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
# the policy's Resource path exactly
#KEEP=7 # how many recent local copies to retain
# (DB dumps and repo bundles are separate series)
#MIN_DB_DUMP_BYTES=2048 # sanity floor for the DB dump (small DB —
# config/issues/PRs/users, no git blobs)
#MIN_REPOS_BUNDLE_BYTES=10240 # sanity floor for the repo tar.gz
#LOCAL_BACKUP_DIR=/opt/gendesign/backups/forgejo
#FORGEJO_DIR=/home/gendesign/forgejo # Forgejo compose dir on the VM (NOT
# part of the gendesign git checkout)
#FORGEJO_REPOS_DIR=/home/gendesign/forgejo/data/forgejo/git/repositories
#PG_CONTAINER=gendesign-postgres-1 # Forgejo's DB lives in the SAME shared
# postgres container as the main app,
# as a separate `forgejo` user+database

View file

@ -21,6 +21,17 @@
#
# To rehearse a restore from a dump this script produced (safe, throwaway
# container, never touches prod) see ops/restore-drill.sh.
#
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
# /etc/default/gendesign-uptime — deliberately a separate config so backup
# alerting doesn't depend on the uptime watchdog's env file existing).
# SAME variable names as ops/gendesign-uptime.default.example — point both
# files at the same bot/chat if you want one Telegram destination for
# everything, or use different bots/chats if you'd rather split the noise.
# Without these two set, ops/check-backup-staleness.sh still logs, just
# doesn't send a Telegram alert.
# --- S3 off-site upload (Selectel S3-compatible). All four required to enable. ---
#S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
@ -28,6 +39,10 @@
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
# --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#KEEP=7 # how many recent local dumps to retain (applies to both
# the main dump series and the globals dump series)

123
ops/lib-backup.sh Executable file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Shared helpers for ops/backup.sh, ops/backup-forgejo.sh, and
# tradein-mvp/deploy/backup-tradein-db.sh (#2203, missed-run detection).
#
# SOURCED, not executed directly — no shebang execution of its own. Inherits
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
# notify() when Telegram vars are actually set — curl is already a hard
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
# new dependency).
#
# Load with (script computes its own dir so this works regardless of cron's
# CWD or which repo subdir the caller lives in):
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "$SCRIPT_DIR/lib-backup.sh" # caller is in ops/
# source "$SCRIPT_DIR/../../ops/lib-backup.sh" # caller is in tradein-mvp/deploy/
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- notify ------------------------------------------------------------
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
# backup alerting doesn't depend on the uptime watchdog's config being
# present, and vice versa. Point both files at the same bot/chat if you want
# one Telegram destination for everything — that's an ops choice, not this
# script's concern.
#
# No-op (logs only) when unset — this is the extension point: to wire a
# different channel later, edit ONLY this function; every caller in this repo
# goes through notify(), never curl/telegram directly.
notify() {
local text="$1"
local backup_env="${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}"
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
# shellcheck source=/dev/null
[[ -f "$backup_env" ]] && source "$backup_env"
fi
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
log "NOTIFY (telegram disabled — set TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID in ${backup_env}): $text"
return 0
fi
curl -fsS --max-time 15 \
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
-d "disable_web_page_preview=true" \
--data-urlencode "text=${text}" \
>/dev/null 2>&1 \
|| log "WARN: telegram sendMessage failed"
}
# --- sentinel (missed-run detection) ------------------------------------
# write_sentinel <path> — call ONLY after every guard in the caller has
# already passed (mirrors the existing "never mark success before a dump is
# verified good" discipline in backup.sh/backup-tradein-db.sh). Content is
# just an ISO-8601 UTC timestamp for human debugging; the actual staleness
# check below is based on the file's mtime, not on parsing that content.
write_sentinel() {
local sentinel_file="$1"
mkdir -p "$(dirname "$sentinel_file")"
date -u +'%Y-%m-%dT%H:%M:%SZ' > "$sentinel_file"
}
# sentinel_age_hours <path> — echoes the sentinel's age in whole hours on
# stdout. Returns 1 (nothing echoed) if the file doesn't exist or its mtime
# can't be read — caller treats that as "stale" (a backup that never
# succeeded is exactly the case this exists to catch).
sentinel_age_hours() {
local sentinel_file="$1" now_epoch sentinel_epoch
[[ -f "$sentinel_file" ]] || return 1
now_epoch=$(date -u +%s)
# GNU stat (prod VM, Debian) first; BSD stat fallback (local/macOS dev).
sentinel_epoch=$(stat -c %Y "$sentinel_file" 2>/dev/null || stat -f %m "$sentinel_file" 2>/dev/null) || return 1
echo $(( (now_epoch - sentinel_epoch) / 3600 ))
}
# --- transition-tracked alert state --------------------------------------
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
# flat "<label> <status>" file, one line per label, so repeated runs alert
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
# single run — avoids Telegram spam from an hourly staleness-check cron.
# Intentionally a separate, independent implementation (not shared code with
# uptime-healthcheck.sh) — that script is out of scope for this change.
backup_prev_status() {
local state_file="$1" label="$2" v
[[ -f "$state_file" ]] || { echo "unknown"; return; }
v="$(grep -E "^${label} " "$state_file" 2>/dev/null | tail -1 | awk '{print $2}')"
echo "${v:-unknown}"
}
backup_set_status() {
local state_file="$1" label="$2" status="$3" tmp
mkdir -p "$(dirname "$state_file")"
tmp="$(mktemp)"
if [[ -f "$state_file" ]]; then
grep -vE "^${label} " "$state_file" > "$tmp" 2>/dev/null || true
fi
echo "${label} ${status}" >> "$tmp"
mv "$tmp" "$state_file"
}
# --- dump integrity (opt-in for NEW scripts only) ------------------------
# Identical logic to the verify_dump_integrity() already duplicated in
# ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh — deliberately
# NOT deduped there (those two are proven/hardened; don't touch working
# code for a refactor nobody asked for). This copy exists so NEW scripts
# (ops/backup-forgejo.sh) get the same guard without a third copy-paste.
verify_dump_integrity() {
local file="$1" trailer="$2" label="$3"
if ! gzip -t "$file" 2>/dev/null; then
log "ERROR: ${label} failed gzip integrity check -> $file" >&2
return 1
fi
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ERROR: ${label} missing trailer '${trailer}' -> $file (truncated dump?)" >&2
return 1
fi
return 0
}

View file

@ -113,7 +113,15 @@ log "Container up, mapped to 127.0.0.1:${host_port} (only reachable while this d
log "Waiting for postgres to accept connections (timeout ${READY_TIMEOUT}s)..."
ready=0
for _ in $(seq 1 "$READY_TIMEOUT"); do
if docker exec "$CONTAINER" pg_isready -U postgres >/dev/null 2>&1; then
# -h 127.0.0.1 is load-bearing, NOT cosmetic. The postgres image runs a
# TEMPORARY server during initialisation, and that server already answers
# "accepting connections" on the unix socket. Probing the socket therefore
# goes green mid-init; the restore starts against the temporary server and
# dies with "FATAL: terminating connection due to administrator command"
# the moment the entrypoint shuts it down to start the real one. The
# temporary server does NOT listen on TCP, so a TCP probe only goes green
# on the real server. Same reasoning, same fix as ci-tradein.yml:137-141.
if docker exec "$CONTAINER" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1; then
ready=1
break
fi

View file

@ -105,3 +105,7 @@ tests/test_2992_upsert_unchanged_gate.py::test_coalesce_backfill_still_updates
tests/test_2992_upsert_unchanged_gate.py::test_next_day_rescrape_updates_even_if_unchanged
tests/test_2992_upsert_unchanged_gate.py::test_skipped_row_still_yields_listing_id_for_downstream
tests/test_2992_upsert_unchanged_gate.py::test_listing_sources_unchanged_rescrape_same_day_does_not_update
# #3036 — house-поля с детальной страницы Авито → houses (fill-only). Live-тест ходит в
# настоящую БД (в CI она есть, #2745), локально без TEST_DATABASE_URL пропускается. Строки
# t3036-* тест удаляет в finally.
tests/test_3036_detail_house_params_to_houses.py::test_live_fill_only_then_keep_then_unlinked_untouched

View file

@ -95,3 +95,65 @@ def test_pipeline_counters_carry_dropped_novostroyki() -> None:
from scraper_kit.orchestration.pipeline import AvitoFullLoadCounters
assert "dropped_novostroyki" in AvitoFullLoadCounters().to_dict()
# ── follow-up (#3033, комментарий владельца 21.08 15:02): якорный и citywide-пути ──────────
# Проверено вживую 21.08 через сайдкар: /prodam/vtorichka-ASgB… принимает geoCoords/radius
# (60 карточек, все vtorichka, total=9036 против 55 карточек 41/14 у общей выдачи).
# Литерал, а не импорт: на main до правки имени SECONDARY_SLUG нет, и импорт ронял бы весь
# файл «нет возможности», а не «неверное значение». Slug — из постановки #3033.
SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg"
def _card(item_id: str) -> str:
href = f"/ekaterinburg/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
return (
f'<div data-marker="item" data-item-id="{item_id}">'
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
f'<meta itemprop="price" content="5000000"></div>'
)
def _capture(method: str, **kw: object) -> list[str]:
s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type]
urls: list[str] = []
async def fake_fetch(url: str, page: int) -> str:
urls.append(url)
return "<html><body>" + _card(f"id{len(urls)}") + "</body></html>"
s._fetch_serp_html = fake_fetch # type: ignore[method-assign]
asyncio.run(getattr(s, method)(**kw))
return urls
def test_anchor_sweep_uses_secondary_slug_by_default() -> None:
"""fetch_around по умолчанию — путь вторички, geoCoords/radius сохранены в query."""
urls = _capture("fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1)
assert urls, "fetch_around не сделал запроса"
u = urlparse(urls[0])
assert u.path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}", u.path
q = parse_qs(u.query)
assert q["geoCoords"] == ["56.84,60.6"] and q["radius"] == ["1"] and q["s"] == ["104"]
def test_anchor_sweep_secondary_only_false_keeps_general_path() -> None:
"""Контроль: secondary_only=False — прежняя общая выдача."""
urls = _capture(
"fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, secondary_only=False
)
assert urlparse(urls[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ"
def test_citywide_uses_secondary_slug_by_default() -> None:
"""fetch_city_wide по умолчанию — путь вторички; secondary_only=False — общая выдача."""
urls = _capture("fetch_city_wide", pages=1)
assert urlparse(urls[0]).path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}"
urls2 = _capture("fetch_city_wide", pages=1, secondary_only=False)
assert urlparse(urls2[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ"
def test_newbuilding_builder_untouched() -> None:
"""Контроль: путь новостроек не меняется (свой slug, свой sweep)."""
s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type]
assert "novostroyka-ASgBAgICAkSSA8YQ5geOUg" in s._build_newbuilding_url(1)

View file

@ -0,0 +1,208 @@
"""Детальная страница Авито больше не выбрасывает house-поля (#3036).
`save_detail_enrichment` парсит has_concierge/closed_yard/total_floors_house/house_type на
каждой карточке и до правки писал в listings только house_type/house_url остальное
терялось. На проде при 10 051 обогащённых карточках с привязкой к дому has_concierge был
заполнен у 5 домов из 10 131, closed_yard у 19. Теперь вторым оператором (fill-only,
через listings.house_id_fk) пустые поля дома заполняются; заполненные не трогаются.
Юнит подменный Session: на main красный по значению (UPDATE houses не исполняется).
Live-тест (skipif без БД): дом + листинг, fill, затем «не затирает», затем «без привязки
дом не тронут».
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
import pytest
from sqlalchemy import text
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.detail import (
DetailEnrichment,
save_detail_enrichment,
)
def _enrichment(**over: Any) -> DetailEnrichment:
base: dict[str, Any] = {
"item_id": "t3036-1",
"source_url": "https://www.avito.ru/ekaterinburg/kvartiry/x_t3036-1",
"has_concierge": True,
"closed_yard": False,
"total_floors_house": 9,
"house_type": "panel",
}
base.update(over)
return DetailEnrichment(**base)
class _FakeNested:
def __enter__(self) -> None:
return None
def __exit__(self, *exc: object) -> bool:
return False
class _FakeSession:
"""Пишет все execute(sql, params); rowcount=1 для обоих операторов."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
self.nested = 0
self.committed = 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
self.calls.append((str(stmt), params or {}))
return SimpleNamespace(rowcount=1)
def begin_nested(self) -> _FakeNested:
self.nested += 1
return _FakeNested()
def commit(self) -> None:
self.committed += 1
def test_house_params_written_fill_only_via_house_id_fk() -> None:
"""Головной: за UPDATE listings идёт UPDATE houses … FROM listings … house_id_fk, fill-only."""
db = _FakeSession()
assert save_detail_enrichment(db, _enrichment()) is True # type: ignore[arg-type]
assert len(db.calls) == 2, [c[0][:40] for c in db.calls]
sql, params = db.calls[1]
assert "UPDATE houses" in sql and "l.house_id_fk = h.id" in sql
for col in ("has_concierge", "closed_yard", "total_floors", "house_type"):
assert f"COALESCE(h.{col}," in sql, f"{col} не fill-only"
assert params["has_concierge"] is True and params["closed_yard"] is False
assert params["total_floors_house"] == 9 and params["house_type"] == "panel"
assert params["item_id"] == "t3036-1"
assert db.nested == 1, "UPDATE houses обязан идти под SAVEPOINT"
assert db.committed == 1
def test_lifts_are_not_written_no_such_columns() -> None:
"""Контроль: лифты не пишем — колонок в houses нет (conflict_resolution их объявляет зря)."""
db = _FakeSession()
save_detail_enrichment(db, _enrichment(passenger_elevators=2, cargo_elevators=1)) # type: ignore[arg-type]
sql = db.calls[1][0]
assert "elevator" not in sql and "lifts" not in sql
def test_nothing_parsed_means_no_second_statement() -> None:
"""Контроль: если с карточки house-полей не пришло — второй оператор не исполняется."""
db = _FakeSession()
save_detail_enrichment( # type: ignore[arg-type]
db,
_enrichment(has_concierge=None, closed_yard=None, total_floors_house=None, house_type=None),
)
assert len(db.calls) == 1 and db.nested == 0
def test_listing_not_found_skips_houses() -> None:
"""Контроль: листинга нет (rowcount 0) — дом не трогаем, False наружу как раньше."""
class _NotFound(_FakeSession):
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
self.calls.append((str(stmt), params or {}))
return SimpleNamespace(rowcount=0)
db = _NotFound()
assert save_detail_enrichment(db, _enrichment()) is False # type: ignore[arg-type]
assert len(db.calls) == 1
# ── live DB ──────────────────────────────────────────────────────────────────
def _live_session() -> Any | None:
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
def _cleanup(db: Any) -> None:
try:
db.rollback()
db.execute(text("DELETE FROM listings WHERE source='avito' AND source_id LIKE 't3036-%'"))
db.execute(text("DELETE FROM houses WHERE source='test' AND ext_house_id LIKE 't3036-%'"))
db.commit()
finally:
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_live_fill_only_then_keep_then_unlinked_untouched() -> None:
db = _live_session()
assert db is not None
try:
_cleanup_inline = text(
"DELETE FROM listings WHERE source='avito' AND source_id LIKE 't3036-%'"
)
db.execute(_cleanup_inline)
db.execute(text("DELETE FROM houses WHERE source='test' AND ext_house_id LIKE 't3036-%'"))
hid = db.execute(
text(
"INSERT INTO houses (source, ext_house_id, url, address) "
"VALUES ('test', 't3036-h1', 'https://example.test/houses/t3036-h1', "
"'ул. Тестовая, 1') RETURNING id"
)
).scalar_one()
db.execute(
text(
"INSERT INTO listings "
"(source, source_id, source_url, dedup_hash, address, price_rub, "
" house_id_fk, is_active) "
"VALUES ('avito', 't3036-1', 'https://www.avito.ru/x/t3036-1', 't3036-dh-1', "
" 'ул. Тестовая, 1', 5000000, :hid, true), "
" ('avito', 't3036-2', 'https://www.avito.ru/x/t3036-2', 't3036-dh-2', "
" 'ул. Тестовая, 2', 5000000, NULL, true)"
),
{"hid": hid},
)
db.commit()
# 1) fill
assert save_detail_enrichment(db, _enrichment()) is True
row = db.execute(
text(
"SELECT has_concierge, closed_yard, total_floors, house_type "
"FROM houses WHERE id=:h"
),
{"h": hid},
).one()
assert tuple(row) == (True, False, 9, "panel"), tuple(row)
# 2) fill-only: другое значение с другой карточки того же дома не затирает
assert (
save_detail_enrichment(db, _enrichment(has_concierge=False, total_floors_house=16))
is True
)
row = db.execute(
text("SELECT has_concierge, total_floors FROM houses WHERE id=:h"), {"h": hid}
).one()
assert tuple(row) == (True, 9), tuple(row)
# 3) листинг без привязки — дом не трогается, листинг обогащается
before = db.execute(
text("SELECT count(*) FROM houses WHERE house_type='brick' AND source='test'")
).scalar_one()
assert (
save_detail_enrichment(db, _enrichment(item_id="t3036-2", house_type="brick")) is True
)
after = db.execute(
text("SELECT count(*) FROM houses WHERE house_type='brick' AND source='test'")
).scalar_one()
assert after == before
finally:
_cleanup(db)

View file

@ -0,0 +1,61 @@
"""Регрессия на размер страницы Avito SERP.
Замер живьём 2026-08-21 (tradein-browser / camoufox, JS исполняется): страница
выдачи отдаёт 59-60 уникальных `data-item-id`. Константа стояла 50 то есть
`ceil(total / PAGE)` завышал число страниц примерно на 17 %, и каждый лишний
запрос лишний шанс словить SERP firewall.
Тест намеренно проверяет НЕ только значение, но и то, что константа
по-прежнему участвует в расчёте страниц: иначе правку значения легко потерять
при рефакторинге, оставив мёртвую константу.
"""
from __future__ import annotations
import ast
import math
from pathlib import Path
from scraper_kit.providers.avito import serp
_SERP_SOURCE = Path(serp.__file__)
def test_offers_per_page_matches_live_measurement() -> None:
"""60, а не 50 — замер 2026-08-21 через реальный браузер."""
assert serp._AVITO_OFFERS_PER_PAGE == 60
def test_pages_needed_arithmetic_uses_the_constant() -> None:
"""`ceil(total / PAGE)` при 60 даёт меньше страниц, чем при 50.
Закрепляет направление эффекта: занижение размера страницы ЗАВЫШАЛО число
запрашиваемых страниц. В PR-разборе это было сформулировано наоборот, и
тест существует, чтобы неверная трактовка не вернулась.
"""
total = 1000
assert math.ceil(total / 60) == 17
assert math.ceil(total / 50) == 20
assert math.ceil(total / serp._AVITO_OFFERS_PER_PAGE) == 17
def test_constant_is_still_wired_into_pagination() -> None:
"""Константа реально используется, а не осталась мёртвой после правки."""
tree = ast.parse(_SERP_SOURCE.read_text(encoding="utf-8"))
uses = sum(
1
for node in ast.walk(tree)
if isinstance(node, ast.Name) and node.id == "_AVITO_OFFERS_PER_PAGE"
)
# 1 присваивание + минимум два места расчёта (pages_needed в бисекции и в
# пагинации листа); если станет меньше — кто-то отвязал константу.
assert uses >= 3, f"_AVITO_OFFERS_PER_PAGE упоминается {uses} раз, ожидалось >= 3"
def test_no_stale_fifty_in_page_size_comment() -> None:
"""Комментарий рядом с константой не должен утверждать «~50 карточек»."""
src = _SERP_SOURCE.read_text(encoding="utf-8")
marker = "_AVITO_OFFERS_PER_PAGE = "
idx = src.index(marker)
preceding = src[max(0, idx - 1200) : idx]
assert "~50 карточек" not in preceding

View file

@ -36,6 +36,16 @@ Per-provider модель (#1793):
BROWSER_RECYCLE_PAGES страниц в одном сеансе браузера до перезапуска (default: 15)
BROWSER_NAV_TIMEOUT_MS таймаут page.goto в мс (default: 60000)
BROWSER_WAIT_MS ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
BROWSER_CHALLENGE_WAIT_MS бюджет ожидания QRATOR PoW-челленджа Авито (#3045),
мс (default: 30000). Челлендж-страница сама считает
proof-of-work в JS, ставит куку pow_solved и через
setTimeout(3000) делает window.location = location.href
(self-reload, URL не меняется). /fetch опрашивает
page.content() пока маркеры челленджа не исчезнут; по
истечении бюджета ChallengeTimeoutError вместо тихой
отдачи заглушки. Действует ТОЛЬКО при обнаружении
маркеров челленджа в разметке прочие провайдеры этот
путь никогда не задевают.
BROWSER_BLOCK_RESOURCE_TYPES CSV типов ресурсов Playwright, которые abort'ить
через page.route при навигации (default: "font,media").
Снижает число одновременных под-коннектов на страницу
@ -113,6 +123,17 @@ BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "6000
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
# 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на
# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это
# ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером
# и egress-прокси решение может занять заметно дольше, чем в обычном браузере
# пользователя. Живой замер 2026-08-21 (#3045): без ожидания челленджа 4 из 6
# карточек с органической навигацией отдавали 7891-байтную челлендж-страницу вместо
# контента — не бан (403/429 не было), просто уходили раньше, чем страница себя
# перезагрузила. 30с — запас с кратным резервом на решение + reload + догидрацию,
# не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе.
BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000"))
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
@ -898,6 +919,138 @@ async def _do_fetch(
raise
# ── QRATOR PoW-челлендж Авито (#3045) ────────────────────────────────────────────
# Маркеры сняты живьём с challenge-страницы Авито 2026-08-21 (замер: 6 карточек,
# органическая навигация из выдачи, 4/6 ушли с челленджа раньше времени). Разметка
# площадки может поменяться со временем — при протухании маркеров переснять их
# заново вживую, а не гадать по памяти. Форма провайдер-агностична: детектор просто
# ищет строки в HTML, другие площадки (cian/yandex/generic) их никогда не отдают,
# поэтому ветка ожидания для них не включается.
# Признак самого челленджа: JS-функция startPow(), которую страница вызывает в
# DOMContentLoaded (см. хвост challenge-скрипта в #3045), либо заголовок блока
# «Доступ ограничен: проверка безопасности» — оба встречались на снятых страницах.
_CHALLENGE_MARKERS: tuple[str, ...] = (
"startpow",
"доступ ограничен: проверка безопасности",
)
# Признак БАН-страницы (не челлендж): «Доступ ограничен: проблема с IP» — статика
# без PoW-скрипта, приходит с 403/429 и заметно меньше challenge-страницы весом.
# Ждать тут бессмысленно — адрес заблокирован, а не временно проверяется.
_BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",)
class ChallengeTimeoutError(Exception):
"""PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS.
Caller должен трактовать как временный отказ (retry/backoff), НЕ как валидный
контент раньше caller получал 7891-байтную challenge-страницу и парсер либо
падал на ней, либо молча ничего не находил (#3045).
"""
class BanPageDetectedError(Exception):
"""Площадка отдала бан-страницу («проблема с IP») вместо контента/челленджа.
В отличие от ChallengeTimeoutError ждать здесь бессмысленно: адрес забанен, а
не проходит временную проверку поднимается сразу, без траты
BROWSER_CHALLENGE_WAIT_MS.
"""
def _is_pow_challenge(html: str) -> bool:
"""True, если HTML — QRATOR PoW-челлендж Авито (см. _CHALLENGE_MARKERS)."""
lower = html.lower()
return any(marker in lower for marker in _CHALLENGE_MARKERS)
def _is_ban_page(html: str) -> bool:
"""True, если HTML — бан-страница «проблема с IP» (см. _BAN_MARKERS)."""
lower = html.lower()
return any(marker in lower for marker in _BAN_MARKERS)
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
# этого случая.
_NAVIGATION_RACE_MARKERS: tuple[str, ...] = (
"execution context was destroyed",
"most likely because of a navigation",
"page is navigating",
)
async def _content_during_navigation(page: object) -> str | None:
"""`page.content()`, устойчивый к перезагрузке страницы под руками.
PoW-челлендж перезагружает себя сам (`window.location = location.href`), и
вызов content(), попавший ровно в этот момент, кидает «Execution context was
destroyed». Для нас это НЕ ошибка, а признак того, что перезагрузка та
самая, которую мы ждём, идёт прямо сейчас. Возвращаем None = «ещё не
устоялось, опроси снова», а не роняем фетч на самом успешном исходе.
Всё остальное (закрытая страница, упавший браузер) пробрасываем как есть.
"""
try:
return await page.content() # type: ignore[attr-defined]
except Exception as exc: # noqa: BLE001 — тип не импортируем, различаем по тексту
text = str(exc).lower()
if any(marker in text for marker in _NAVIGATION_RACE_MARKERS):
return None
raise
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
решения PoW URL не меняется, поэтому page.wait_for_url тут не годится;
опрашиваем контент с шагом ~1с вместо этого. По истечении
BROWSER_CHALLENGE_WAIT_MS ChallengeTimeoutError, а не тихая отдача
challenge-страницы как будто это валидный контент.
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
каким ждём обычную навигацию (второй таймаут не изобретаем).
"""
poll_interval_ms = 1000
elapsed_ms = 0
html: str | None = await _content_during_navigation(page)
while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
elapsed_ms += poll_interval_ms
html = await _content_during_navigation(page)
if html is None or _is_pow_challenge(html):
raise ChallengeTimeoutError(
f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
)
logger.info(
"tradein-browser[%s]: PoW-челлендж снят за ~%dмс, догидрация url=%r",
provider,
elapsed_ms,
url,
)
if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
settled = await _content_during_navigation(page)
if settled is None:
# Догидрация совпала с ещё одной навигацией — даём один короткий добор
# вместо того, чтобы падать: контент уже не challenge, гонка чисто
# техническая.
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
settled = await _content_during_navigation(page)
if settled is None:
raise ChallengeTimeoutError(
f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась "
f"(навигация не прекращается) url={url!r}"
)
return settled
async def _fetch_once(
provider: str,
url: str,
@ -950,6 +1103,17 @@ async def _fetch_once(
if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
html: str = await page.content() # type: ignore[attr-defined]
# Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты
# BROWSER_CHALLENGE_WAIT_MS — ждать там нечего, адрес заблокирован (#3045).
if _is_ban_page(html):
raise BanPageDetectedError(
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
)
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
if _is_pow_challenge(html):
html = await _wait_out_pow_challenge(page, provider, url)
finally:
await page.close() # type: ignore[attr-defined]

View file

@ -0,0 +1,273 @@
"""test_server_pow_challenge.py — QRATOR PoW-челлендж Авито в /fetch (#3045).
Проблема, установленная замером на проде 2026-08-21: Авито за QRATOR отдаёт
proof-of-work челлендж (JS считает PoW, ставит куку pow_solved, через
setTimeout(3000) делает `window.location = location.href` self-reload, URL не
меняется). Фиксированной паузы BROWSER_WAIT_MS (6с) на цепочку
«PoW таймер 3с перезагрузка гидрация» хватало не всегда: живой замер дал
2 успеха из 6 карточек, 4 отказа все «проверка безопасности», НЕ бан по IP.
Тестируется _fetch_once целиком (не только вынесенный polling-хелпер), чтобы
проверить весь путь: goto content() детект (ожидание | ошибка) return.
camoufox НЕ запускается: _browsers[provider] поддельный browser/page,
возвращающие заранее заданную последовательность content(). wait_for_timeout
на фейковой page no-op (без реального asyncio.sleep), поэтому тесты идут
мгновенно независимо от величины BROWSER_CHALLENGE_WAIT_MS/BROWSER_WAIT_MS.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_pow_challenge.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py)."""
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_retry_tasks", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
_CHALLENGE_HTML = (
"<html><body><script>"
"document.addEventListener('DOMContentLoaded', function() {"
" if (getCookie('pow_solved')) {"
" setTimeout(function() { window.location = location.href; }, 3000);"
" return;"
" }"
" startPow(1);"
"});"
"</script></body></html>"
)
_BAN_HTML = "<html><body>Доступ ограничен: проблема с IP</body></html>"
_REAL_HTML = "<html><body>listing card content</body></html>"
class _ChallengePage:
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
После исчерпания списка повторяет последний элемент (имитирует «страница
осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для
проверки, что бюджет ожидания не тратится там, где не должен.
Элемент последовательности может быть исключением тогда content() его
поднимает. Это нужно, чтобы воспроизвести гонку с self-reload челленджа:
playwright кидает «Execution context was destroyed» ровно в момент той
перезагрузки, которую мы ждём, и на моках без этого дефект не виден.
"""
def __init__(self, html_sequence: list[str | Exception]) -> None:
self._html_sequence = html_sequence
self._call_count = 0
self.goto_urls: list[str] = []
self.wait_for_timeout_calls: list[int] = []
self.closed = 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms)
async def content(self) -> str:
idx = min(self._call_count, len(self._html_sequence) - 1)
item = self._html_sequence[idx]
self._call_count += 1
if isinstance(item, Exception):
raise item
return item
async def close(self) -> None:
self.closed += 1
class _ChallengeBrowser:
def __init__(self, page: _ChallengePage) -> None:
self._page = page
async def new_page(self) -> _ChallengePage:
return self._page
def _install(
monkeypatch: pytest.MonkeyPatch, page: _ChallengePage, provider: str = "avito"
) -> None:
server._browsers[provider] = _ChallengeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
# ── детекторы: чистые функции ────────────────────────────────────────────────────
def test_is_pow_challenge_true_on_start_pow_marker() -> None:
assert server._is_pow_challenge(_CHALLENGE_HTML) is True
def test_is_pow_challenge_true_on_title_marker() -> None:
html = "<html><body>Доступ ограничен: проверка безопасности</body></html>"
assert server._is_pow_challenge(html) is True
def test_is_pow_challenge_false_on_ban_page() -> None:
"""Бан-страница НЕ должна ложно матчиться как челлендж — разные ветки."""
assert server._is_pow_challenge(_BAN_HTML) is False
def test_is_pow_challenge_false_on_real_content() -> None:
assert server._is_pow_challenge(_REAL_HTML) is False
def test_is_ban_page_true_on_ip_problem_marker() -> None:
assert server._is_ban_page(_BAN_HTML) is True
def test_is_ban_page_false_on_challenge_page() -> None:
assert server._is_ban_page(_CHALLENGE_HTML) is False
def test_is_ban_page_false_on_real_content() -> None:
assert server._is_ban_page(_REAL_HTML) is False
def test_challenge_wait_budget_defaults_to_30s(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолт BROWSER_CHALLENGE_WAIT_MS=30000 без переопределения env."""
monkeypatch.delenv("BROWSER_CHALLENGE_WAIT_MS", raising=False)
import os
assert int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) == 30000
# ── _fetch_once: сценарии ────────────────────────────────────────────────────────
def test_fetch_once_waits_out_challenge_then_returns_real_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Челлендж на первых двух опросах, третий — уже реальный контент → он и вернётся."""
page = _ChallengePage([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert html == _REAL_HTML
assert page.closed == 1
# Обычная гидро-пауза + минимум одна пауза опроса челленджа + финальная
# догидрация тем же BROWSER_WAIT_MS (второй таймаут не изобретаем).
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
assert 1000 in page.wait_for_timeout_calls # шаг опроса
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Челлендж не снимается никогда → ChallengeTimeoutError, а не заглушка-контент."""
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
page = _ChallengePage([_CHALLENGE_HTML]) # всегда челлендж (последний элемент повторяется)
_install(monkeypatch, page)
with pytest.raises(server.ChallengeTimeoutError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
# finally всё равно закрывает страницу, несмотря на исключение.
assert page.closed == 1
def test_fetch_once_raises_ban_error_immediately_without_spending_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Бан-страница («проблема с IP») → своя ошибка сразу, БЕЗ цикла опроса."""
page = _ChallengePage([_BAN_HTML])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert page.closed == 1
# Единственный wait_for_timeout — обычная гидро-пауза ДО детекта; опроса
# челленджа (доп. паузы по 1000мс) не было — бюджет не потрачен.
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
def test_fetch_once_normal_page_without_markers_unaffected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Страница без маркеров челленджа/бана → поведение как раньше, без polling-ветки."""
page = _ChallengePage([_REAL_HTML])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert html == _REAL_HTML
assert page.closed == 1
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
assert page.goto_urls == ["https://www.avito.ru/card/1"]
# ── гонка с self-reload челленджа (#3045, найдено при ревью ветки) ──────────────
#
# Челлендж перезагружает страницу САМ. Вызов page.content(), попавший ровно в этот
# момент, кидает «Execution context was destroyed» — то есть цикл ожидания падал бы
# именно на успешном исходе, ради которого написан. На моках без явной имитации
# это не воспроизводится, поэтому тесты ниже поднимают исключение из content().
_NAV_RACE = RuntimeError(
"Execution context was destroyed, most likely because of a navigation."
)
def test_navigation_race_during_reload_is_not_a_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""content() упал на перезагрузке → опрашиваем дальше, отдаём настоящий HTML."""
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE, _REAL_HTML])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert html == _REAL_HTML
def test_permanent_navigation_race_raises_challenge_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Навигация не прекращается → своя ошибка, а не сырое исключение playwright."""
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE])
_install(monkeypatch, page)
with pytest.raises(server.ChallengeTimeoutError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
def test_unrelated_content_error_still_propagates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Глушим ТОЛЬКО гонку навигации; упавший браузер должен всплыть как есть."""
boom = RuntimeError("Target page, context or browser has been closed")
page = _ChallengePage([_CHALLENGE_HTML, boom])
_install(monkeypatch, page)
with pytest.raises(RuntimeError, match="has been closed"):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))

View file

@ -23,15 +23,26 @@
# S3_BUCKET=gendesign-backups
# S3_ACCESS_KEY=...
# S3_SECRET_KEY=...
#
# Детект пропущенного запуска (#2203): по успеху пишется sentinel-файл
# (SENTINEL_FILE) — отдельный cron-запуск ops/check-backup-staleness.sh
# следит за его возрастом и шлёт алерт, если он не свежел (см. заголовок
# того скрипта — канал алертов и cron-строка). Сам этот скрипт не алертит,
# только отмечает успех.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../ops/lib-backup.sh
source "$SCRIPT_DIR/../../ops/lib-backup.sh"
BACKUP_DIR="${BACKUP_DIR:-/opt/gendesign/backups/tradein}"
KEEP="${KEEP:-7}" # сколько копий хранить
PG_CONTAINER="${PG_CONTAINER:-tradein-postgres}"
MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-10240}" # 10 KiB floor — пустая/битая
# схема заведомо меньше, живая
# БД — на порядки больше.
SENTINEL_FILE="${SENTINEL_FILE:-${BACKUP_DIR}/.last_success}"
# S3-переменные: свой env-файл, а если его нет — общий с main-бэкапом.
if [[ -f /etc/default/tradein-backup ]]; then
@ -40,8 +51,6 @@ elif [[ -f /etc/default/gendesign-backup ]]; then
source /etc/default/gendesign-backup
fi
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск
# кончился, OOM-kill, оборвался docker exec) может дать структурно валидный,
# не крошечный .gz — ни `-s`, ни MIN_DUMP_BYTES это не ловят. Две проверки,
@ -155,4 +164,9 @@ ls -1t "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null \
size=$(du -h "$out" | cut -f1)
count=$(ls -1 "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
globals_count=$(ls -1 "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"
log "backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"
# Детект пропущенного запуска (#2203): отмечаем успех только тут, после всех
# проверок выше (и — под `set -euo pipefail` — после S3-выгрузки, если она
# включена). См. ops/check-backup-staleness.sh.
write_sentinel "$SENTINEL_FILE"

View file

@ -6,7 +6,8 @@
bathroom_type, windows_view, repair_state, sale_type, mortgage_available)
- Location (lat, lon, avito_location_id, metro_stations[], address_full)
- House params (house_type, total_floors_house, lifts, concierge, closed_yard,
house_catalog_url) собираются но НЕ сохраняются в БД (Stage 2c)
house_catalog_url) house_type/total_floors/concierge/closed_yard доезжают в
houses (fill-only, через listings.house_id_fk, #3036); лифты — нет колонок в houses
- Description
- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date,
encumbrances_clean, registry_match)
@ -860,6 +861,60 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
# ── save_detail_enrichment ────────────────────────────────────────────────────
# #3036: house-level поля с детальной страницы → houses (fill-only). Колонок под лифты
# в houses нет — passenger/cargo_elevators намеренно не в списке.
_HOUSE_PARAMS_SQL = text("""
UPDATE houses h SET
has_concierge = COALESCE(h.has_concierge, CAST(:has_concierge AS boolean)),
closed_yard = COALESCE(h.closed_yard, CAST(:closed_yard AS boolean)),
total_floors = COALESCE(h.total_floors, CAST(:total_floors_house AS integer)),
house_type = COALESCE(h.house_type, CAST(:house_type AS text))
FROM listings l
WHERE l.source = 'avito' AND l.source_id = :item_id
AND l.house_id_fk = h.id
AND (h.has_concierge IS NULL AND CAST(:has_concierge AS boolean) IS NOT NULL
OR h.closed_yard IS NULL AND CAST(:closed_yard AS boolean) IS NOT NULL
OR h.total_floors IS NULL AND CAST(:total_floors_house AS integer) IS NOT NULL
OR h.house_type IS NULL AND CAST(:house_type AS text) IS NOT NULL)
""")
def _fill_house_params_from_detail(db: Session, e: DetailEnrichment) -> int:
"""Заполнить пустые house-поля дома листинга значениями с детальной страницы (#3036).
Возвращает число обновлённых домов (0 листинг без house_id_fk, у дома всё уже
заполнено, либо с карточки ничего не пришло). Ошибка оператора глушится под
SAVEPOINT с warning: обогащение листинга важнее.
"""
if (
e.has_concierge is None
and e.closed_yard is None
and e.total_floors_house is None
and e.house_type is None
):
return 0
try:
with db.begin_nested():
r = db.execute(
_HOUSE_PARAMS_SQL,
{
"item_id": e.item_id,
"has_concierge": e.has_concierge,
"closed_yard": e.closed_yard,
"total_floors_house": e.total_floors_house,
"house_type": e.house_type,
},
)
except Exception:
logger.warning(
"save_detail_enrichment: house params not saved for item_id=%s",
e.item_id,
exc_info=True,
)
return 0
return int(r.rowcount or 0)
def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
"""UPDATE listings SET <25+ cols> WHERE source='avito' AND source_id=:item_id.
@ -872,9 +927,14 @@ def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
house_catalog_url колонки нет. house_type COALESCE existing-first (не затираем
canonical из Houses Catalog Stage 2c); house_url new-first additive (avito-owned
поле, нет конкурирующего canonical-писателя).
Остальные house-level поля (passenger_elevators, cargo_elevators, has_concierge,
closed_yard, total_floors_house) по-прежнему опускаются нет колонок в listings,
canonical приходит из Houses Catalog Stage 2c (отдельный follow-up).
House-level поля (has_concierge, closed_yard, total_floors_house, house_type)
пишутся в houses ЧЕРЕЗ listings.house_id_fk вторым оператором (#3036), fill-only:
COALESCE(houses.X, новое) не затираем канон из Houses Catalog Stage 2c, а
заполняем пустое. До этого они парсились на каждой карточке и выбрасывались:
на проде has_concierge был заполнен у 5 домов из 10 131, closed_yard у 19,
при 10 051 обогащённых карточках Авито с привязкой к дому. Лифты
(passenger_elevators/cargo_elevators) по-прежнему опускаются в houses нет колонок.
Под SAVEPOINT: отказ этого оператора не должен ронять обогащение самого листинга.
"""
result = db.execute(
text("""
@ -952,6 +1012,8 @@ def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
"house_catalog_url": e.house_catalog_url,
},
)
if result.rowcount > 0:
_fill_house_params_from_detail(db, e)
db.commit()
found = result.rowcount > 0
if not found:

View file

@ -242,6 +242,11 @@ NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg"
# сегмент. Цена вопроса: вторичка — 20,6 % общей выдачи ЕКБ, остальное раньше качалось
# и отбрасывалось после разбора (страницы и антибан-бюджет уже потрачены).
SECONDARY_PATH_SEGMENT = "vtorichka"
# Канонический slug категории «вторичка» — для путей БЕЗ комнатности (якорный
# fetch_around и citywide): редиректа не нужно, запрос один. Проверено вживую 21.08.2026
# через сайдкар с geoCoords/radius: 60 карточек, все vtorichka, total=9036 (против 55 карточек
# 41/14 у общей выдачи на том же якоре) — гео-параметры путь принимает.
SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg"
# ── Exhaustive full-load (room×price bisection) ───────────────────────────────
# Верхняя граница цены при первом рекурсивном делении (нет явного hi).
@ -261,8 +266,22 @@ _AVITO_MIN_BRACKET = 50_000
# Последний брекет ОТКРЫТ (hi=None → pmax не ставится) — ловит весь хвост люкса
# без потолка; он крошечный (avito >50М ≈ 73), пагинируется напрямую без бисекции.
_AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_brackets()
# Avito SERP показывает ~50 карточек на страницу.
_AVITO_OFFERS_PER_PAGE = 50
# Avito SERP отдаёт 60 карточек на страницу. Замер живьём 2026-08-21 через
# tradein-browser (camoufox, JS исполняется): 59-60 уникальных `data-item-id`
# на странице выдачи. «Лишние» сверх 50 — обычные объявления с платным
# продвижением (`vas-icon_type-promoted`), они лежат в том же списке под
# `page-title/count`, а не отдельным рекламным блоком, и собираются наравне.
#
# Константа стояла 50 и участвует ТОЛЬКО в `ceil(total / PAGE)` — то есть
# занижение размера страницы ЗАВЫШАЛО расчётное число страниц, а не занижало:
# - запрашивали примерно на 17 % страниц больше, чем нужно (лишние запросы,
# лишняя экспозиция под SERP firewall — при нашей доле банов это и есть
# основная цена ошибки);
# - `tail_loss` считался как `total - cap * 50` и завышал потерю;
# - `complete` (пагинация листа) чаще ложно показывал «неполно».
# Тихой потери данных не было: условия «страница вернула меньше PAGE карточек,
# значит последняя» в коде нет — пагинация ограничена только `max_pages`.
_AVITO_OFFERS_PER_PAGE = 60
def _avito_bisection_config(cap: int) -> BisectionConfig:
@ -829,9 +848,14 @@ class AvitoScraper(BaseScraper):
*,
pages: int = 1,
delay_override_sec: float | None = None,
secondary_only: bool = True,
) -> list[ScrapedLot]:
"""Найти объявления Авито вокруг (lat, lon) в radius_m метрах.
secondary_only (default True, #3033): путь вторички вместо общей выдачи — класс и
есть парсер вторички, новостройки ходят своим fetch_newbuildings. До этого якорный
city-sweep качал общую выдачу (в прогоне 4539: 41 vtorichka + 26 novostroyki).
Avito работает в км конвертируем. Минимум 1 км.
pages=1 (default) backward compat, одна страница (~50 lots).
@ -848,7 +872,7 @@ class AvitoScraper(BaseScraper):
all_lots: list[ScrapedLot] = []
for page in range(1, pages + 1):
url = self._build_web_url(lat, lon, radius_km, page=page)
url = self._build_web_url(lat, lon, radius_km, page=page, secondary=secondary_only)
try:
html = await self._fetch_serp_html(url, page)
except (AvitoBlockedError, AvitoRateLimitedError):
@ -905,28 +929,38 @@ class AvitoScraper(BaseScraper):
билдерах скоупил oblast city-sweep на ЕКБ, невзирая на geoCoords/фильтр)."""
return self._target_city_slug or "ekaterinburg"
def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str:
def _category_path(self, *, secondary: bool) -> str:
"""Сегмент категории: родной фильтр вторички (#3033) либо общая выдача."""
if secondary:
return f"/{self._city_seg()}/kvartiry/prodam/{SECONDARY_SLUG}"
return f"/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
def _build_web_url(
self,
lat: float,
lon: float,
radius_km: int,
page: int = 1,
*,
secondary: bool = False,
) -> str:
"""URL якорного поиска (geoCoords + radius). secondary=True — путь вторички (#3033)."""
params = {
"geoCoords": f"{lat},{lon}",
"radius": radius_km,
"s": 104, # sort by date
"p": page,
}
return (
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
f"?{urlencode(params)}"
)
return f"{self.base_url}{self._category_path(secondary=secondary)}?{urlencode(params)}"
def _build_citywide_url(self, page: int = 1) -> str:
def _build_citywide_url(self, page: int = 1, *, secondary: bool = False) -> str:
"""T6: URL всего города (по умолчанию ЕКБ) без geo-фильтра, сортировка по дате.
Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек).
secondary=True путь вторички (#3033): cap тот же, но выдача в 5 раз уже.
"""
params = {"s": 104, "p": page}
return (
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
f"?{urlencode(params)}"
)
return f"{self.base_url}{self._category_path(secondary=secondary)}?{urlencode(params)}"
def _build_newbuilding_url(self, page: int = 1) -> str:
"""URL городской выборки только новостроек (novostroyka-filter), сортировка по дате.
@ -1722,9 +1756,12 @@ class AvitoScraper(BaseScraper):
pages: int = 100,
*,
delay_override_sec: float | None = None,
secondary_only: bool = True,
) -> list[ScrapedLot]:
"""T6: Обход всего ЕКБ без geo-фильтра (citywide-mode), paginated.
secondary_only (default True, #3033): путь вторички вместо общей выдачи.
Возвращает дедуплицированный список лотов (по source_id/source_url).
Break-on-empty: останавливается когда страница отдаёт 0 карточек.
Сохраняет весь anti-block pipeline (_fetch_serp_html: firewall-detect,
@ -1740,7 +1777,7 @@ class AvitoScraper(BaseScraper):
"""
all_lots = await self._paginate_sweep(
pages,
self._build_citywide_url,
lambda page: self._build_citywide_url(page, secondary=secondary_only),
label="citywide",
delay_override_sec=delay_override_sec,
)