gendesign/tradein-mvp/scripts/local-cian/playwright_history.py

256 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cian history backfill через real Chrome (Playwright) — обходит anti-bot detection.
Iterates Cian listings без offer_price_history, открывает в headless Chrome,
extract'ит defaultState.offerData.priceChanges через page.evaluate, пишет в БД.
Requires:
pip install playwright "sqlalchemy>=2" "psycopg[binary]"
playwright install chromium
Run:
$env:TRADEIN_DB_DSN = "postgresql+psycopg://tradein:PASS@localhost:15433/tradein"
python C:\\Users\\user\\cian-backfill\\playwright_history.py --batch 20 --delay 2
Flags:
--headed показать браузер (debug)
--batch N листингов за батч (default 20)
--delay S seconds между listings (default 2)
--max N hard cap (default 5500)
"""
from __future__ import annotations
import argparse
import asyncio
import os
import signal
import sys
import time
from datetime import datetime, timezone
import json
import psycopg
from playwright.async_api import async_playwright
EXTRACT_JS = """
() => {
if (!window._cianConfig || !window._cianConfig['frontend-offer-card']) {
return { error: 'no _cianConfig', captcha: location.href.includes('captcha') };
}
const arr = window._cianConfig['frontend-offer-card'];
const def = arr.find(e => e && e.key === 'defaultState');
if (!def || !def.value) {
return { error: 'no defaultState' };
}
const v = def.value;
const offerData = v.offerData || {};
const offer = offerData.offer || {};
// priceChanges живёт в offerData.priceChanges (НЕ в offerData.offer.priceChanges)
const priceChanges = offerData.priceChanges || offer.priceChanges || v.priceChanges || [];
// bargainTerms.price — текущая цена
const price_rub = (offer.bargainTerms && offer.bargainTerms.price) || null;
// Также собрать ключи offerData чтобы видеть что доступно
return {
cian_id: offer.id || null,
price_rub,
price_changes: priceChanges,
_offerData_keys: Object.keys(offerData).slice(0, 25),
_offer_keys: Object.keys(offer).slice(0, 25),
};
}
"""
def parse_change_time(s):
"""Parse 'YYYY-MM-DDTHH:MM:SS' or ISO → timestamptz; return raw string if parse fails."""
if not s:
return None
return s # psycopg позволяет передать строку → postgres парсит как timestamptz
def upsert_changes(cur, listing_id: int, price_rub: int | None, changes: list) -> int:
"""Insert priceChanges array; return count inserted (ignoring conflicts)."""
if not changes:
return 0
rows = []
prev_price = None
for c in changes:
ct = c.get("changeTime") or c.get("date")
# priceData.price — основной формат 2026+; fallback на старые поля
pd = c.get("priceData") if isinstance(c.get("priceData"), dict) else None
pr = (pd.get("price") if pd else None) or c.get("price") or c.get("priceRub")
if not ct or not pr:
continue
diff_pct = None
if prev_price is not None and prev_price > 0:
diff_pct = round((pr - prev_price) / prev_price * 100, 2)
rows.append((listing_id, ct, pr, diff_pct, "cian"))
prev_price = pr
if not rows:
return 0
cur.executemany(
"""
INSERT INTO offer_price_history (listing_id, change_time, price_rub, diff_percent, source)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (listing_id, change_time) DO NOTHING
""",
rows,
)
return cur.rowcount or 0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--batch", type=int, default=20)
ap.add_argument("--delay", type=float, default=2.0)
ap.add_argument("--max", type=int, default=5500)
ap.add_argument("--headed", action="store_true")
args = ap.parse_args()
dsn = os.environ.get("TRADEIN_DB_DSN")
if not dsn:
sys.exit("FATAL: TRADEIN_DB_DSN env required")
# psycopg native dsn (без +psycopg для sqlalchemy)
pg_dsn = dsn.replace("postgresql+psycopg://", "postgresql://")
interrupted = False
def on_sigint(s, f):
nonlocal interrupted
print("\n!! Ctrl+C — finishing current item")
interrupted = True
signal.signal(signal.SIGINT, on_sigint)
async def run() -> None:
nonlocal interrupted
cum_ok = cum_fail = cum_changes = cum_with_history = 0
start = time.time()
last_id = 0 # paginate forward — избегаем loop когда changes=0
# Optional: load cookies из БД для авторизованной сессии
cookies_from_db: list = []
try:
with psycopg.connect(pg_dsn) as conn:
with conn.cursor() as cur:
enc_key = os.environ.get("COOKIE_ENCRYPTION_KEY")
if enc_key:
cur.execute(
"""
SELECT pgp_sym_decrypt(cookies_encrypted, %s)::text
FROM cian_session_cookies
WHERE expires_at_estimate > NOW() AND last_invalid_at IS NULL
ORDER BY uploaded_at DESC LIMIT 1
""",
(enc_key,),
)
r = cur.fetchone()
if r:
jar = json.loads(r[0])
for name, val in jar.items():
cookies_from_db.append({
"name": name, "value": val,
"domain": ".cian.ru", "path": "/",
"secure": True, "sameSite": "Lax",
})
print(f"[init] loaded {len(cookies_from_db)} cookies from DB")
except Exception as exc:
print(f"[init] cookies load skipped: {exc}")
async with async_playwright() as p:
browser = await p.chromium.launch(headless=not args.headed)
ctx = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
viewport={"width": 1366, "height": 900},
locale="ru-RU",
)
if cookies_from_db:
await ctx.add_cookies(cookies_from_db)
page = await ctx.new_page()
page.set_default_navigation_timeout(45_000)
page.set_default_timeout(45_000)
processed = 0
while not interrupted and processed < args.max:
# 1. Fetch batch — paginate id > last_id чтобы не зацикливаться
with psycopg.connect(pg_dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT l.id, l.source_url
FROM listings l
LEFT JOIN offer_price_history oph ON oph.listing_id = l.id
WHERE l.source = 'cian'
AND l.source_url IS NOT NULL
AND oph.listing_id IS NULL
AND l.id > %s
ORDER BY l.id
LIMIT %s
""",
(last_id, args.batch),
)
rows = cur.fetchall()
if not rows:
print(f"\n[done] drained — no more listings")
break
print(f"\n[batch] {len(rows)} listings to process (cum: ok={cum_ok} fail={cum_fail} changes={cum_changes})")
# 2. Process each
with psycopg.connect(pg_dsn, autocommit=False) as conn:
for (lid, url) in rows:
if interrupted:
break
processed += 1
t0 = time.time()
try:
await page.goto(url, wait_until="domcontentloaded")
# wait for _cianConfig populated
await page.wait_for_function(
"() => window._cianConfig && window._cianConfig['frontend-offer-card'] && window._cianConfig['frontend-offer-card'].some(e => e.key === 'defaultState')",
timeout=15_000,
)
data = await page.evaluate(EXTRACT_JS)
if data.get("error"):
if data.get("captcha"):
print(f" [{lid}] CAPTCHA — please solve in browser (use --headed)")
interrupted = True
break
cum_fail += 1
print(f" [{lid}] {data['error']} {time.time() - t0:.1f}s")
continue
changes = data.get("price_changes") or []
with conn.cursor() as cur:
inserted = upsert_changes(cur, lid, data.get("price_rub"), changes)
conn.commit()
cum_ok += 1
cum_changes += inserted
if len(changes) > 0:
cum_with_history += 1
print(f" [{lid}] OK cian_id={data.get('cian_id')} price={data.get('price_rub')} changes={len(changes)} inserted={inserted} {time.time() - t0:.1f}s")
# На первом обращении и затем каждые 50 — dump для diagnostics
if processed == 1 or processed % 50 == 0:
print(f" [diag] offerData_keys={data.get('_offerData_keys')}")
print(f" [diag] offer_keys={data.get('_offer_keys')}")
# Dump shape priceChanges первые 3 раза + при первом успехе
if (changes and processed <= 3) or (changes and inserted > 0 and cum_changes == inserted):
print(f" [diag] price_changes_sample={changes[0]}")
last_id = lid # advance pagination cursor even on 0-change result
except Exception as exc:
conn.rollback()
cum_fail += 1
print(f" [{lid}] ERR {type(exc).__name__}: {str(exc)[:120]} {time.time() - t0:.1f}s")
await asyncio.sleep(args.delay)
await browser.close()
print()
print(f"== Summary ==")
print(f" processed_ok={cum_ok} fail={cum_fail}")
print(f" listings_with_history={cum_with_history} price_changes_inserted={cum_changes}")
print(f" elapsed={time.time() - start:.1f}s finished={datetime.now(timezone.utc).isoformat(timespec='seconds')}")
asyncio.run(run())
return 0
if __name__ == "__main__":
sys.exit(main())