From d0b73b93198d1afab2595df2ac0c0b14f16dee73 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Wed, 14 Jan 2026 21:54:55 +0100 Subject: [PATCH] codex2 --- .coverage | Bin 53248 -> 53248 bytes .env | 21 + .env.example | 3 + .gitignore | 0 CHANGELOG.md | 38 +- Dockerfile | 33 + Image collée (2).png | Bin 0 -> 139518 bytes Image collée (3).png | Bin 0 -> 17888 bytes Image collée (4).png | Bin 0 -> 18512 bytes Image collée.png | Bin 0 -> 68164 bytes MIGRATION_GUIDE.md | 83 + PHASE_1_COMPLETE.md | 0 PHASE_2_PROGRESS.md | 118 +- README.md | 64 + TODO.md | 42 +- alembic.ini | 0 docker-compose.yml | 26 + pricewatch.egg-info/PKG-INFO | 73 +- pricewatch.egg-info/SOURCES.txt | 1 + pricewatch.egg-info/requires.txt | 2 + pricewatch/app/api/__init__.py | 5 + .../api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 273 bytes .../app/api/__pycache__/main.cpython-313.pyc | Bin 0 -> 42062 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 10004 bytes pricewatch/app/api/main.py | 876 +++++++++ pricewatch/app/api/schemas.py | 212 +++ .../app/cli/__pycache__/main.cpython-313.pyc | Bin 20375 -> 24888 bytes pricewatch/app/cli/main.py | 81 +- .../core/__pycache__/config.cpython-313.pyc | Bin 7958 -> 8220 bytes .../core/__pycache__/schema.cpython-313.pyc | Bin 8809 -> 8505 bytes pricewatch/app/core/config.py | 6 + pricewatch/app/core/io.py | 6 +- pricewatch/app/core/schema.py | 20 +- pricewatch/app/db/__init__.py | 2 + .../db/__pycache__/__init__.cpython-313.pyc | Bin 813 -> 841 bytes .../db/__pycache__/connection.cpython-313.pyc | Bin .../app/db/__pycache__/models.cpython-313.pyc | Bin 12364 -> 13841 bytes .../db/__pycache__/repository.cpython-313.pyc | Bin pricewatch/app/db/connection.py | 0 .../__pycache__/env.cpython-313.pyc | Bin pricewatch/app/db/migrations/env.py | 0 pricewatch/app/db/migrations/script.py.mako | 0 .../versions/20260114_01_initial_schema.py | 0 .../versions/20260114_02_webhooks.py | 35 + .../versions/20260115_02_product_details.py | 26 + ...20260114_01_initial_schema.cpython-313.pyc | Bin pricewatch/app/db/models.py | 48 +- pricewatch/app/db/repository.py | 4 + .../__pycache__/__init__.cpython-313.pyc | Bin .../__pycache__/pipeline.cpython-313.pyc | Bin pricewatch/app/scraping/pipeline.py | 0 .../__pycache__/price_parser.cpython-313.pyc | Bin 0 -> 2126 bytes pricewatch/app/stores/aliexpress/store.py | 52 +- .../amazon/__pycache__/store.cpython-313.pyc | Bin pricewatch/app/stores/amazon/selectors.yml | 4 +- pricewatch/app/stores/amazon/store.py | 97 +- pricewatch/app/stores/backmarket/store.py | 45 +- pricewatch/app/stores/cdiscount/store.py | 119 +- pricewatch/app/stores/price_parser.py | 48 + pricewatch/app/tasks/__init__.py | 13 +- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 471 bytes .../__pycache__/scheduler.cpython-313.pyc | Bin 0 -> 7435 bytes .../tasks/__pycache__/scrape.cpython-313.pyc | Bin 0 -> 8314 bytes pricewatch/app/tasks/scheduler.py | 75 +- pricewatch/app/tasks/scrape.py | 33 + pyproject.toml | 4 + scrap_url.yaml | 4 +- scraped_store.json | 125 +- .../test_auth.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 4369 bytes ..._backend_logs.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 2807 bytes ...lters_exports.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 14814 bytes .../test_health.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 3614 bytes ...p_integration.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6960 bytes ...test_products.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 3130 bytes ...ape_endpoints.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 5977 bytes ..._uvicorn_logs.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 2405 bytes .../test_version.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 1125 bytes ...test_webhooks.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 8289 bytes tests/api/test_auth.py | 56 + tests/api/test_backend_logs.py | 30 + tests/api/test_filters_exports.py | 239 +++ tests/api/test_health.py | 40 + tests/api/test_http_integration.py | 47 + tests/api/test_products.py | 37 + tests/api/test_scrape_endpoints.py | 55 + tests/api/test_uvicorn_logs.py | 16 + tests/api/test_version.py | 11 + tests/api/test_webhooks.py | 72 + ...er_end_to_end.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 10390 bytes ..._schedule_cli.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 9856 bytes .../test_run_db.cpython-313-pytest-9.0.2.pyc | Bin ...est_run_no_db.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 8280 bytes ...st_worker_cli.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 5977 bytes tests/cli/test_cli_worker_end_to_end.py | 130 ++ tests/cli/test_enqueue_schedule_cli.py | 83 + tests/cli/test_run_db.py | 0 tests/cli/test_run_no_db.py | 106 ++ tests/cli/test_worker_cli.py | 54 + .../test_io.cpython-313-pytest-9.0.2.pyc | Bin ...y_integration.cpython-313-pytest-9.0.2.pyc | Bin tests/core/test_io.py | 0 tests/core/test_registry_integration.py | 0 ...k_persistence.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 3546 bytes ...st_connection.cpython-313-pytest-9.0.2.pyc | Bin .../test_models.cpython-313-pytest-9.0.2.pyc | Bin 8164 -> 8358 bytes ...st_repository.cpython-313-pytest-9.0.2.pyc | Bin tests/db/test_bulk_persistence.py | 40 + tests/db/test_connection.py | 0 tests/db/test_models.py | 7 +- tests/db/test_repository.py | 0 tests/scraping/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin ...st_http_fetch.cpython-313-pytest-9.0.2.pyc | Bin ...test_pipeline.cpython-313-pytest-9.0.2.pyc | Bin 7087 -> 9814 bytes ...test_pw_fetch.cpython-313-pytest-9.0.2.pyc | Bin tests/scraping/test_http_fetch.py | 0 tests/scraping/test_pipeline.py | 30 + tests/scraping/test_pw_fetch.py | 0 .../test_amazon.cpython-313-pytest-9.0.2.pyc | Bin ..._price_parser.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 5261 bytes tests/stores/test_price_parser.py | 29 + ..._redis_errors.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11370 bytes ...est_scheduler.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 19584 bytes ...t_scrape_task.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 8803 bytes ...er_end_to_end.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 9367 bytes tests/tasks/test_redis_errors.py | 127 ++ tests/tasks/test_scheduler.py | 184 ++ tests/tasks/test_scrape_task.py | 91 + tests/tasks/test_worker_end_to_end.py | 110 ++ webui/Dockerfile | 13 + webui/index.html | 13 + webui/nginx.conf | 17 + webui/package.json | 22 + webui/postcss.config.js | 6 + webui/public/favicon.svg | 5 + webui/src/App.vue | 1566 +++++++++++++++++ webui/src/index.css | 281 +++ webui/src/main.js | 6 + webui/tailwind.config.js | 7 + webui/vite.config.js | 9 + 140 files changed, 5822 insertions(+), 161 deletions(-) mode change 100755 => 100644 .coverage create mode 100644 .env mode change 100755 => 100644 .env.example mode change 100755 => 100644 .gitignore create mode 100644 Dockerfile create mode 100755 Image collée (2).png create mode 100755 Image collée (3).png create mode 100755 Image collée (4).png create mode 100755 Image collée.png create mode 100644 MIGRATION_GUIDE.md mode change 100755 => 100644 PHASE_1_COMPLETE.md mode change 100755 => 100644 PHASE_2_PROGRESS.md mode change 100755 => 100644 alembic.ini mode change 100755 => 100644 docker-compose.yml mode change 100755 => 100644 pricewatch.egg-info/PKG-INFO create mode 100644 pricewatch/app/api/__init__.py create mode 100644 pricewatch/app/api/__pycache__/__init__.cpython-313.pyc create mode 100644 pricewatch/app/api/__pycache__/main.cpython-313.pyc create mode 100644 pricewatch/app/api/__pycache__/schemas.cpython-313.pyc create mode 100644 pricewatch/app/api/main.py create mode 100644 pricewatch/app/api/schemas.py mode change 100755 => 100644 pricewatch/app/cli/__pycache__/main.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/core/__pycache__/config.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/core/__pycache__/schema.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/core/config.py mode change 100755 => 100644 pricewatch/app/db/__init__.py mode change 100755 => 100644 pricewatch/app/db/__pycache__/__init__.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/db/__pycache__/connection.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/db/__pycache__/models.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/db/__pycache__/repository.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/db/connection.py mode change 100755 => 100644 pricewatch/app/db/migrations/__pycache__/env.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/db/migrations/env.py mode change 100755 => 100644 pricewatch/app/db/migrations/script.py.mako mode change 100755 => 100644 pricewatch/app/db/migrations/versions/20260114_01_initial_schema.py create mode 100644 pricewatch/app/db/migrations/versions/20260114_02_webhooks.py create mode 100644 pricewatch/app/db/migrations/versions/20260115_02_product_details.py mode change 100755 => 100644 pricewatch/app/db/migrations/versions/__pycache__/20260114_01_initial_schema.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/db/models.py mode change 100755 => 100644 pricewatch/app/db/repository.py mode change 100755 => 100644 pricewatch/app/scraping/__pycache__/__init__.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/scraping/__pycache__/pipeline.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/scraping/pipeline.py create mode 100644 pricewatch/app/stores/__pycache__/price_parser.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/stores/amazon/__pycache__/store.cpython-313.pyc create mode 100644 pricewatch/app/stores/price_parser.py mode change 100755 => 100644 pricewatch/app/tasks/__init__.py create mode 100644 pricewatch/app/tasks/__pycache__/__init__.cpython-313.pyc create mode 100644 pricewatch/app/tasks/__pycache__/scheduler.cpython-313.pyc create mode 100644 pricewatch/app/tasks/__pycache__/scrape.cpython-313.pyc mode change 100755 => 100644 pricewatch/app/tasks/scheduler.py mode change 100755 => 100644 pricewatch/app/tasks/scrape.py mode change 100755 => 100644 scraped_store.json create mode 100644 tests/api/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_backend_logs.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_filters_exports.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_health.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_http_integration.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_products.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_scrape_endpoints.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_uvicorn_logs.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_version.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/__pycache__/test_webhooks.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/api/test_auth.py create mode 100644 tests/api/test_backend_logs.py create mode 100644 tests/api/test_filters_exports.py create mode 100644 tests/api/test_health.py create mode 100644 tests/api/test_http_integration.py create mode 100644 tests/api/test_products.py create mode 100644 tests/api/test_scrape_endpoints.py create mode 100644 tests/api/test_uvicorn_logs.py create mode 100644 tests/api/test_version.py create mode 100644 tests/api/test_webhooks.py create mode 100644 tests/cli/__pycache__/test_cli_worker_end_to_end.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/cli/__pycache__/test_enqueue_schedule_cli.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/cli/__pycache__/test_run_db.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/cli/__pycache__/test_run_no_db.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/cli/__pycache__/test_worker_cli.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/cli/test_cli_worker_end_to_end.py create mode 100644 tests/cli/test_enqueue_schedule_cli.py mode change 100755 => 100644 tests/cli/test_run_db.py create mode 100644 tests/cli/test_run_no_db.py create mode 100644 tests/cli/test_worker_cli.py mode change 100755 => 100644 tests/core/__pycache__/test_io.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/core/__pycache__/test_registry_integration.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/core/test_io.py mode change 100755 => 100644 tests/core/test_registry_integration.py create mode 100644 tests/db/__pycache__/test_bulk_persistence.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/db/__pycache__/test_connection.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/db/__pycache__/test_models.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/db/__pycache__/test_repository.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/db/test_bulk_persistence.py mode change 100755 => 100644 tests/db/test_connection.py mode change 100755 => 100644 tests/db/test_models.py mode change 100755 => 100644 tests/db/test_repository.py mode change 100755 => 100644 tests/scraping/__init__.py mode change 100755 => 100644 tests/scraping/__pycache__/__init__.cpython-313.pyc mode change 100755 => 100644 tests/scraping/__pycache__/test_http_fetch.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/scraping/__pycache__/test_pipeline.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/scraping/__pycache__/test_pw_fetch.cpython-313-pytest-9.0.2.pyc mode change 100755 => 100644 tests/scraping/test_http_fetch.py mode change 100755 => 100644 tests/scraping/test_pipeline.py mode change 100755 => 100644 tests/scraping/test_pw_fetch.py mode change 100755 => 100644 tests/stores/__pycache__/test_amazon.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/stores/__pycache__/test_price_parser.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/stores/test_price_parser.py create mode 100644 tests/tasks/__pycache__/test_redis_errors.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/tasks/__pycache__/test_scheduler.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/tasks/__pycache__/test_scrape_task.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/tasks/__pycache__/test_worker_end_to_end.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/tasks/test_redis_errors.py create mode 100644 tests/tasks/test_scheduler.py create mode 100644 tests/tasks/test_scrape_task.py create mode 100644 tests/tasks/test_worker_end_to_end.py create mode 100644 webui/Dockerfile create mode 100644 webui/index.html create mode 100644 webui/nginx.conf create mode 100644 webui/package.json create mode 100644 webui/postcss.config.js create mode 100644 webui/public/favicon.svg create mode 100644 webui/src/App.vue create mode 100644 webui/src/index.css create mode 100644 webui/src/main.js create mode 100644 webui/tailwind.config.js create mode 100644 webui/vite.config.js diff --git a/.coverage b/.coverage old mode 100755 new mode 100644 index e2106b9cbfe7735ed707871ea76aa5bbb79d732f..719073e4848b5f297c6a9111a0d82ae5ef5ed4c9 GIT binary patch delta 1254 zcmZva%Wo4`6vjRFJUnyf&UIp(SWS%cP%D*6u^|#*;pPD(amM4g-6g8a)EJXk&BL-o zA~ppt`%seVNFd(OKcGR%4mK=c0g8B4s?;`0noXocs-RLALp)vuie$Y*OD5ckLfu&OdHid)C=mc+D!f;S4f65Do>Rk zm43x5-;$@~kG-qj1#h49Li$x2mp&CYm+E|%M0Zy^e36a>rnBX5%aL-flr82vil=+q zcvnS&8^&irM;XhXI5nOxq1e-kqF{;ss%V;p$-=a0qLyfZJNkYI>V6@Nt|rR`YY7RC z>jC%RZn&Ayg%Ap%H$pwc2Z_6{8PdrpJlCmGo}MaM7bKc+Q33`AF9`9V$92Y8M2~07 zmVTrWw-9^!T~emSS@UFmIyZ(&Ujr`d7Xr09JWQ&>uG$H$Il9{qEyg~0WN2JHtQv85 zcO4FTI3HwH&Weemiv1WCg*te&m zT5f9cWWfp?R6MR(xRHuNdPs1`WLQXd&}^{~naCC@pAm@Sp<-g-Uw0CuT%BMT`4bLkl*YRknQ zuz5|TPc%cjr~N>KbcCK)FRCNzpX4g}S`CpE5>$Rtep3dR;r-d$FMr})lW)sKMV7Xu zMJ7lS(jIXOiMlL8+Kj%7Q~P1QB5_t(AHb=tm#+x6SpObQt*|~aD7F}UrO9?UC~;v} z9M;9%DZUbN1REtT1Vdl#wMdd3lA3YH0a(-}c%>5<7`e!Q?m6RxB!{QqQNBW|Q{+jl z;i(Zf_KOWWbmWPMfgK~&DGSTl1>sC5@nLDTFI+ z?)C2^N~>m4F^*ck_n1BSiR$51FyB!SXMQU#O*{R89?FfkZJ6Ht+t34S|RgxSsf=KFrX`S#0i zChHbuwrfT@QWLPaxP+QLOiHMtM6%H@O;h-` z%mjX@$x`GY$;~Flw`5j-Hftm~N1Dic#IP6Y zclBQLsQJ3Vjig;-Rv15ly*Zt)SU3gT4B=9~y}6O+7Jk5c7o^m9S^-17W{N2v2UBs3 zUuvFB=jmsZ(@k_O&Cv{fsk1k4x(tnzoBYu~iv%)$OYa<+XpPrk~^<82@oGYKt5@3%XBH}Zq) zUoPp1CkJc3*q)fUt!wlXTKE2+H{#=8?%%xm);B$)Ezl>ag#KkMhfhu@uAb^cqlX6@2pUoUPQasU32#O$u3UTOgU zlqrpui|VK-LU{>ccf(Ce)ps1pC=csEkJY3X*AsAO*$W*;n}Tq;53;lL&hvd22T!#J zk^9j}av7D+CyM6`7(BI9^UC@pr{Z3ntew4QwGDO+n>>=BAVP|xslZYdJBo^p0#c+E zNALf#(?J8Mwh~z`zI|&Q>z?QU&+Y5KF$ab<#ted^XRZd9 z5Q_=0q5`530gmOn{#XV735i39yL(!xUf|0yJHK?$f-($39Jj Ms(Ro`&+w{$0TjN4g#Z8m diff --git a/.env b/.env new file mode 100644 index 0000000..289289d --- /dev/null +++ b/.env @@ -0,0 +1,21 @@ +# Database +PW_DB_HOST=localhost +PW_DB_PORT=5432 +PW_DB_DATABASE=pricewatch +PW_DB_USER=pricewatch +PW_DB_PASSWORD=pricewatch + +# Redis +PW_REDIS_HOST=localhost +PW_REDIS_PORT=6379 +PW_REDIS_DB=0 + +# App +PW_DEBUG=false +PW_WORKER_TIMEOUT=300 +PW_WORKER_CONCURRENCY=2 +PW_ENABLE_DB=true +PW_ENABLE_WORKER=true + +# API +PW_API_TOKEN=change_me diff --git a/.env.example b/.env.example old mode 100755 new mode 100644 index a89bb87..289289d --- a/.env.example +++ b/.env.example @@ -16,3 +16,6 @@ PW_WORKER_TIMEOUT=300 PW_WORKER_CONCURRENCY=2 PW_ENABLE_DB=true PW_ENABLE_WORKER=true + +# API +PW_API_TOKEN=change_me diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/CHANGELOG.md b/CHANGELOG.md index 90643a3..623e305 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,10 @@ Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) ## [Non publié] +**Dernière mise à jour**: 2026-01-15 + ### En cours -- Phase 2 : Base de données PostgreSQL -- Phase 2 : Worker Redis/RQ -- Phase 3 : API REST FastAPI +- Phase 3 : API REST FastAPI (filtres/exports/webhooks) - Phase 4 : Web UI ### Ajouté @@ -26,6 +26,38 @@ Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) - Tests repository/pipeline (SQLite) - Test end-to-end CLI + DB (SQLite) - Worker RQ + scheduler (tasks + CLI) +- Tests worker/scheduler (SQLite + mocks) +- Tests CLI worker/enqueue/schedule + erreur DB (SQLite) +- Gestion erreurs Redis (RedisUnavailableError, check_redis_connection) +- Messages d'erreur clairs pour Redis down dans CLI (worker, enqueue, schedule) +- 7 nouveaux tests pour la gestion des erreurs Redis +- Logs d'observabilité pour jobs planifiés (JOB START/OK/FAILED, FETCH, PARSE) +- Tests end-to-end worker + DB (Redis/SQLite, skip si Redis down) +- Test end-to-end CLI -> DB -> worker (Redis, skip si Redis down) +- Guide de migration JSON -> DB +- API FastAPI (health/products/prices/logs/enqueue/schedule) + auth token +- Docker API + uvicorn +- Tests API de base +- Docker Compose API: port 8001 et hosts postgres/redis +- CRUD API (products/prices/logs) +- Filtres avances API (prix, dates, stock, status) +- Exports API CSV/JSON (products, prices, logs) +- Webhooks API (CRUD + test) +- Tests compatibilite `--no-db` (CLI) +- Test charge legere 100 snapshots (SQLite) +- Nettoyage warnings (Pydantic ConfigDict, datetime UTC, selectors SoupSieve) +- Web UI Vue 3 (layout dense, themes, settings) + Docker compose frontend +- Web UI: integration API (list produits, edition, enqueue, settings API) +- API: endpoints preview/commit scraping pour ajout produit depuis l UI +- Web UI: ajout produit par URL avec preview scraping et sauvegarde en base +- Web UI: popup ajout produit central + favicon +- API: logs Uvicorn exposes pour l UI +- Parsing prix: gestion des separateurs de milliers (espace, NBSP, point) +- API/DB: description + msrp + images/specs exposes, reduction calculee + +### Corrigé +- Migration Alembic: down_revision aligne sur 20260114_02 +- Amazon: extraction images via data-a-dynamic-image + filtrage logos --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b16f119 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN mkdir -p /app/logs + +COPY pyproject.toml README.md alembic.ini ./ +COPY pricewatch ./pricewatch + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libglib2.0-0 \ + libgbm1 \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libgtk-3-0 \ + libxkbcommon0 \ + libxcomposite1 \ + libxrandr2 \ + libxinerama1 \ + libasound2 \ + libpangocairo-1.0-0 \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir -e . + +EXPOSE 8000 + +CMD ["sh", "-c", "uvicorn pricewatch.app.api.main:app --host 0.0.0.0 --port 8000 2>&1 | tee /app/logs/uvicorn.log"] diff --git a/Image collée (2).png b/Image collée (2).png new file mode 100755 index 0000000000000000000000000000000000000000..a3d7df360356a6ff7dbfecd76b2888fc692fd4ff GIT binary patch literal 139518 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 literal 0 HcmV?d00001 diff --git a/Image collée (4).png b/Image collée (4).png new file mode 100755 index 0000000000000000000000000000000000000000..93aae9fd82ff53bee85fc10857b556bd419d2f45 GIT binary patch literal 18512 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 literal 0 HcmV?d00001 diff --git a/Image collée.png b/Image collée.png new file mode 100755 index 0000000000000000000000000000000000000000..6888ae606d973a5428fa980b54ace4f092d6952b GIT binary patch literal 68164 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 VJQm>fxPR0l_S`7`CE%O;{tracy4wH% diff --git a/pricewatch/app/core/config.py b/pricewatch/app/core/config.py old mode 100755 new mode 100644 index 66e4e7e..84bd36f --- a/pricewatch/app/core/config.py +++ b/pricewatch/app/core/config.py @@ -108,6 +108,11 @@ class AppConfig(BaseSettings): default=True, description="Enable background worker functionality" ) + # API auth + api_token: Optional[str] = Field( + default=None, description="API token simple (Bearer)" + ) + # Scraping defaults default_playwright_timeout: int = Field( default=60000, description="Default Playwright timeout in milliseconds" @@ -138,6 +143,7 @@ class AppConfig(BaseSettings): logger.info(f"Worker enabled: {self.enable_worker}") logger.info(f"Worker timeout: {self.worker_timeout}s") logger.info(f"Worker concurrency: {self.worker_concurrency}") + logger.info(f"API token configured: {bool(self.api_token)}") logger.info("================================") diff --git a/pricewatch/app/core/io.py b/pricewatch/app/core/io.py index 2e99c46..7a6bef7 100755 --- a/pricewatch/app/core/io.py +++ b/pricewatch/app/core/io.py @@ -23,6 +23,9 @@ class ScrapingOptions(BaseModel): use_playwright: bool = Field( default=True, description="Utiliser Playwright en fallback" ) + force_playwright: bool = Field( + default=False, description="Forcer Playwright même si HTTP réussi" + ) headful: bool = Field(default=False, description="Mode headful (voir le navigateur)") save_html: bool = Field( default=True, description="Sauvegarder HTML pour debug" @@ -94,7 +97,8 @@ def read_yaml_config(yaml_path: str | Path) -> ScrapingConfig: config = ScrapingConfig.model_validate(data) logger.info( f"Configuration chargée: {len(config.urls)} URL(s), " - f"playwright={config.options.use_playwright}" + f"playwright={config.options.use_playwright}, " + f"force_playwright={config.options.force_playwright}" ) return config diff --git a/pricewatch/app/core/schema.py b/pricewatch/app/core/schema.py index dde2503..9c45228 100755 --- a/pricewatch/app/core/schema.py +++ b/pricewatch/app/core/schema.py @@ -9,7 +9,7 @@ from datetime import datetime from enum import Enum from typing import Optional -from pydantic import BaseModel, Field, HttpUrl, field_validator +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator class StockStatus(str, Enum): @@ -38,6 +38,8 @@ class DebugStatus(str, Enum): class DebugInfo(BaseModel): """Informations de debug pour tracer les problèmes de scraping.""" + model_config = ConfigDict(use_enum_values=True) + method: FetchMethod = Field( description="Méthode utilisée pour la récupération (http ou playwright)" ) @@ -55,9 +57,6 @@ class DebugInfo(BaseModel): default=None, description="Taille du HTML récupéré en octets" ) - class Config: - use_enum_values = True - class ProductSnapshot(BaseModel): """ @@ -81,6 +80,7 @@ class ProductSnapshot(BaseModel): # Données produit principales title: Optional[str] = Field(default=None, description="Nom du produit") price: Optional[float] = Field(default=None, description="Prix du produit", ge=0) + msrp: Optional[float] = Field(default=None, description="Prix conseille", ge=0) currency: str = Field(default="EUR", description="Devise (EUR, USD, etc.)") shipping_cost: Optional[float] = Field( default=None, description="Frais de port", ge=0 @@ -94,6 +94,7 @@ class ProductSnapshot(BaseModel): default=None, description="Référence produit (ASIN, SKU, etc.)" ) category: Optional[str] = Field(default=None, description="Catégorie du produit") + description: Optional[str] = Field(default=None, description="Description produit") # Médias images: list[str] = Field( @@ -133,20 +134,22 @@ class ProductSnapshot(BaseModel): """Filtre les URLs d'images vides.""" return [url.strip() for url in v if url and url.strip()] - class Config: - use_enum_values = True - json_schema_extra = { + model_config = ConfigDict( + use_enum_values=True, + json_schema_extra={ "example": { "source": "amazon", "url": "https://www.amazon.fr/dp/B08N5WRWNW", "fetched_at": "2026-01-13T10:30:00Z", "title": "Exemple de produit", "price": 299.99, + "msrp": 349.99, "currency": "EUR", "shipping_cost": 0.0, "stock_status": "in_stock", "reference": "B08N5WRWNW", "category": "Electronics", + "description": "Chargeur USB-C multi-ports.", "images": [ "https://example.com/image1.jpg", "https://example.com/image2.jpg", @@ -165,7 +168,8 @@ class ProductSnapshot(BaseModel): "html_size_bytes": 145000, }, } - } + }, + ) def to_dict(self) -> dict: """Serialize vers un dictionnaire Python natif.""" diff --git a/pricewatch/app/db/__init__.py b/pricewatch/app/db/__init__.py old mode 100755 new mode 100644 index c466e97..0bceaa0 --- a/pricewatch/app/db/__init__.py +++ b/pricewatch/app/db/__init__.py @@ -20,6 +20,7 @@ from pricewatch.app.db.models import ( ProductImage, ProductSpec, ScrapingLog, + Webhook, ) __all__ = [ @@ -30,6 +31,7 @@ __all__ = [ "ProductImage", "ProductSpec", "ScrapingLog", + "Webhook", "ProductRepository", # Connection "get_engine", diff --git a/pricewatch/app/db/__pycache__/__init__.cpython-313.pyc b/pricewatch/app/db/__pycache__/__init__.cpython-313.pyc old mode 100755 new mode 100644 index 6d900a4bb8697ef5f2acf106e0f061cbaf4fbbfd..81bdad1d51d0f139873b9dd1b8da7fa4f421864e GIT binary patch delta 127 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 diff --git a/pricewatch/app/db/__pycache__/repository.cpython-313.pyc b/pricewatch/app/db/__pycache__/repository.cpython-313.pyc old mode 100755 new mode 100644 diff --git a/pricewatch/app/db/connection.py b/pricewatch/app/db/connection.py old mode 100755 new mode 100644 diff --git a/pricewatch/app/db/migrations/__pycache__/env.cpython-313.pyc b/pricewatch/app/db/migrations/__pycache__/env.cpython-313.pyc old mode 100755 new mode 100644 diff --git a/pricewatch/app/db/migrations/env.py b/pricewatch/app/db/migrations/env.py old mode 100755 new mode 100644 diff --git a/pricewatch/app/db/migrations/script.py.mako b/pricewatch/app/db/migrations/script.py.mako old mode 100755 new mode 100644 diff --git a/pricewatch/app/db/migrations/versions/20260114_01_initial_schema.py b/pricewatch/app/db/migrations/versions/20260114_01_initial_schema.py old mode 100755 new mode 100644 diff --git a/pricewatch/app/db/migrations/versions/20260114_02_webhooks.py b/pricewatch/app/db/migrations/versions/20260114_02_webhooks.py new file mode 100644 index 0000000..7e0ee83 --- /dev/null +++ b/pricewatch/app/db/migrations/versions/20260114_02_webhooks.py @@ -0,0 +1,35 @@ +"""Add webhooks table + +Revision ID: 20260114_02 +Revises: 20260114_01 +Create Date: 2026-01-14 00:00:00 +""" + +from alembic import op +import sqlalchemy as sa + +# Revision identifiers, used by Alembic. +revision = "20260114_02" +down_revision = "20260114_01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "webhooks", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("event", sa.String(length=50), nullable=False), + sa.Column("url", sa.Text(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("secret", sa.String(length=200), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False), + ) + op.create_index("ix_webhook_event", "webhooks", ["event"], unique=False) + op.create_index("ix_webhook_enabled", "webhooks", ["enabled"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_webhook_enabled", table_name="webhooks") + op.drop_index("ix_webhook_event", table_name="webhooks") + op.drop_table("webhooks") diff --git a/pricewatch/app/db/migrations/versions/20260115_02_product_details.py b/pricewatch/app/db/migrations/versions/20260115_02_product_details.py new file mode 100644 index 0000000..7c6d053 --- /dev/null +++ b/pricewatch/app/db/migrations/versions/20260115_02_product_details.py @@ -0,0 +1,26 @@ +"""Ajout description et msrp sur products. + +Revision ID: 20260115_02_product_details +Revises: 20260114_02 +Create Date: 2026-01-15 10:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20260115_02_product_details" +down_revision = "20260114_02" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("products", sa.Column("description", sa.Text(), nullable=True)) + op.add_column("products", sa.Column("msrp", sa.Numeric(10, 2), nullable=True)) + + +def downgrade() -> None: + op.drop_column("products", "msrp") + op.drop_column("products", "description") diff --git a/pricewatch/app/db/migrations/versions/__pycache__/20260114_01_initial_schema.cpython-313.pyc b/pricewatch/app/db/migrations/versions/__pycache__/20260114_01_initial_schema.cpython-313.pyc old mode 100755 new mode 100644 diff --git a/pricewatch/app/db/models.py b/pricewatch/app/db/models.py old mode 100755 new mode 100644 index 096a0cf..20693b1 --- a/pricewatch/app/db/models.py +++ b/pricewatch/app/db/models.py @@ -15,7 +15,7 @@ Justification technique: - JSONB uniquement pour données variables: errors, notes dans logs """ -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal from typing import List, Optional @@ -28,6 +28,7 @@ from sqlalchemy import ( Integer, JSON, Numeric, + Boolean, String, Text, UniqueConstraint, @@ -42,6 +43,10 @@ class Base(DeclarativeBase): pass +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + class Product(Base): """ Catalogue produits (1 ligne par produit unique). @@ -70,19 +75,25 @@ class Product(Base): category: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="Product category (breadcrumb)" ) + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="Product description" + ) currency: Mapped[Optional[str]] = mapped_column( String(3), nullable=True, comment="Currency code (EUR, USD, GBP)" ) + msrp: Mapped[Optional[Decimal]] = mapped_column( + Numeric(10, 2), nullable=True, comment="Recommended price" + ) # Timestamps first_seen_at: Mapped[datetime] = mapped_column( - TIMESTAMP, nullable=False, default=datetime.utcnow, comment="First scraping timestamp" + TIMESTAMP, nullable=False, default=utcnow, comment="First scraping timestamp" ) last_updated_at: Mapped[datetime] = mapped_column( TIMESTAMP, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=utcnow, + onupdate=utcnow, comment="Last metadata update", ) @@ -280,7 +291,7 @@ class ScrapingLog(Base): String(20), nullable=False, comment="Fetch status (success, partial, failed)" ) fetched_at: Mapped[datetime] = mapped_column( - TIMESTAMP, nullable=False, default=datetime.utcnow, comment="Scraping timestamp" + TIMESTAMP, nullable=False, default=utcnow, comment="Scraping timestamp" ) # Performance metrics @@ -318,3 +329,30 @@ class ScrapingLog(Base): def __repr__(self) -> str: return f"" + + +class Webhook(Base): + """ + Webhooks pour notifications externes. + """ + + __tablename__ = "webhooks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + event: Mapped[str] = mapped_column(String(50), nullable=False, comment="Event name") + url: Mapped[str] = mapped_column(Text, nullable=False, comment="Webhook URL") + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + secret: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True, comment="Secret optionnel" + ) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP, nullable=False, default=utcnow, comment="Creation timestamp" + ) + + __table_args__ = ( + Index("ix_webhook_event", "event"), + Index("ix_webhook_enabled", "enabled"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/pricewatch/app/db/repository.py b/pricewatch/app/db/repository.py old mode 100755 new mode 100644 index 5474b98..d0b451c --- a/pricewatch/app/db/repository.py +++ b/pricewatch/app/db/repository.py @@ -49,8 +49,12 @@ class ProductRepository: product.title = snapshot.title if snapshot.category: product.category = snapshot.category + if snapshot.description: + product.description = snapshot.description if snapshot.currency: product.currency = snapshot.currency + if snapshot.msrp is not None: + product.msrp = snapshot.msrp def add_price_history(self, product: Product, snapshot: ProductSnapshot) -> Optional[PriceHistory]: """Ajoute une entree d'historique de prix si inexistante.""" diff --git a/pricewatch/app/scraping/__pycache__/__init__.cpython-313.pyc b/pricewatch/app/scraping/__pycache__/__init__.cpython-313.pyc old mode 100755 new mode 100644 diff --git a/pricewatch/app/scraping/__pycache__/pipeline.cpython-313.pyc b/pricewatch/app/scraping/__pycache__/pipeline.cpython-313.pyc old mode 100755 new mode 100644 diff --git a/pricewatch/app/scraping/pipeline.py b/pricewatch/app/scraping/pipeline.py old mode 100755 new mode 100644 diff --git a/pricewatch/app/stores/__pycache__/price_parser.cpython-313.pyc b/pricewatch/app/stores/__pycache__/price_parser.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0725abafc431d9656568e508de1a4b6deb559256 GIT binary patch literal 2126 zcmcgtO>ERg6rQoY>s@cw3He1Jz-_3?R>8}UP$~#f3q_lNNUB&1hqPTe-ih(bv8}O} z0tXbemqa;0s-mpaLl;!3$y!qan_l;N6X$isjMNdGri!OipHQVpK&RFvMZZW4#V4?=2c!&MMWReu zwr#-019dHf5_YmwJW7%yQS|W<{5_>YTAN>?!zSfAdRSl9_JgNc1v}GL?J14h}b-jO=+XLas>->>1e4X(?6&hu^x&V8>cYK+#S|$n#NkbvQXM z$gHNkj1KdoI4?MObacJO!!O0RUc>(%-)=OXTCWI>^4g1DLE|max`=e>I0fy24xdEF z`8{Y7XIjc8*rj(F*OaoO1msdc7_%CbBaSj@yLzBXQjSof*ucf-8AyCjH%K4w)pCM9 z<)TH+vP~2cC~Fp9u^j)qMMIx}ovJkQ8>7#+)etLX*8D$C-)VklL-Ybcne{Lu=m#M5 z0%%6k4m8w@kh^WGri%y@U1Kr9u1Q0TB1Jg$>>j3HL^omzIt%Tcf|w@m=b;4L6;ACN zfww}m{Gw%AL52%C^+3oO7kq|SQNp*g7u+e5HQ}`(e%7OIi5P*O^$n_fpjrm`P!Eg? zS>5xpe&EuE6RZ~2-Z0g>93}@$;*d)o9SnOLtA)nC0xP&WV7rEH`-AGbQzsnkD=@8~ zX|&Wee5=1QcBiyhx??Ze_a?r1=j+nK#Qemq{y%(36n(UNKmH=QABVUv#Oewo?#kaV zGGZuF;@NOXp-_TL3S)HAa*AQh_iQT&d7Hpe2g6^e#UbccXH zKo0y+EE2<-(rqY}6A%*~UPd#84VnfiY}=d~RbZL@Ec_Cnnnu63pq93JYsX``{hB>% zSKh2@H97M@KDaC&yu0t-$(sDu19@m!9{PU!l6I~ppP$av<+f|)thwAZe6zpSHC&U2 zr*n@|?JS5W&6powt@KuVK2z(d_G`yyk5@Wssa;j^%k<6kZKI|fs-+IqyLTYW++g)$#SxC>PD@LvD)tT7Clsr?OJ6geWgZ1v6 zl@s$83$%T~oHr|^da^oJDb>0&Gr30x`YSz^WOZA$znWR@?z?w>Cim<=kN8C3W1SCM zJFlOgySkLv_3UA)YZc*G=MSwNPk8wL>9<48@ApclRCHgJP95X#AH^__WCXesEFtd) zG>lUcl&N#2HTp@8H(KHSOgt0|g`#T|3ZYaeOqRim6bh8FGK>e8J Optional[str]: + """Extrait la description (meta tags).""" + meta = soup.find("meta", property="og:description") or soup.find( + "meta", attrs={"name": "description"} + ) + if meta: + description = meta.get("content", "").strip() + if description: + return description + return None + def _extract_price( self, html: str, soup: BeautifulSoup, debug: DebugInfo ) -> Optional[float]: @@ -193,35 +209,39 @@ class AliexpressStore(BaseStore): On utilise regex sur le HTML brut. """ # Pattern 1: Prix avant € (ex: "136,69 €") - match = re.search(r"([0-9]+[.,][0-9]{2})\s*€", html) + match = re.search(r"([0-9][0-9\\s.,\\u00a0\\u202f\\u2009]*)\\s*€", html) if match: - price_str = match.group(1).replace(",", ".") - try: - return float(price_str) - except ValueError: - pass + price = parse_price_text(match.group(1)) + if price is not None: + return price # Pattern 2: € avant prix (ex: "€ 136.69") - match = re.search(r"€\s*([0-9]+[.,][0-9]{2})", html) + match = re.search(r"€\\s*([0-9][0-9\\s.,\\u00a0\\u202f\\u2009]*)", html) if match: - price_str = match.group(1).replace(",", ".") - try: - return float(price_str) - except ValueError: - pass + price = parse_price_text(match.group(1)) + if price is not None: + return price # Pattern 3: Chercher dans meta tags (moins fiable) og_price = soup.find("meta", property="og:price:amount") if og_price: price_str = og_price.get("content", "") - try: - return float(price_str) - except ValueError: - pass + price = parse_price_text(price_str) + if price is not None: + return price debug.errors.append("Prix non trouvé") return None + def _extract_msrp(self, html: str, debug: DebugInfo) -> Optional[float]: + """Extrait le prix conseille si present.""" + match = re.search(r"originalPrice\"\\s*:\\s*\"([0-9\\s.,]+)\"", html) + if match: + price = parse_price_text(match.group(1)) + if price is not None: + return price + return None + def _extract_currency( self, url: str, soup: BeautifulSoup, debug: DebugInfo ) -> str: diff --git a/pricewatch/app/stores/amazon/__pycache__/store.cpython-313.pyc b/pricewatch/app/stores/amazon/__pycache__/store.cpython-313.pyc old mode 100755 new mode 100644 diff --git a/pricewatch/app/stores/amazon/selectors.yml b/pricewatch/app/stores/amazon/selectors.yml index 2bd8ad3..652ab5e 100755 --- a/pricewatch/app/stores/amazon/selectors.yml +++ b/pricewatch/app/stores/amazon/selectors.yml @@ -54,12 +54,12 @@ specs_table: # ASIN (parfois dans les métadonnées) asin: - "input[name='ASIN']" - - "th:contains('ASIN') + td" + - "th:-soup-contains('ASIN') + td" # Messages captcha / robot check captcha_indicators: - "form[action*='validateCaptcha']" - - "p.a-last:contains('Sorry')" + - "p.a-last:-soup-contains('Sorry')" - "img[alt*='captcha']" # Notes pour le parsing: diff --git a/pricewatch/app/stores/amazon/store.py b/pricewatch/app/stores/amazon/store.py index a2bdaca..7426a9d 100755 --- a/pricewatch/app/stores/amazon/store.py +++ b/pricewatch/app/stores/amazon/store.py @@ -4,7 +4,9 @@ Store Amazon - Parsing de produits Amazon.fr et Amazon.com. Supporte l'extraction de: titre, prix, ASIN, images, specs, etc. """ +import json import re +from html import unescape from datetime import datetime from pathlib import Path from typing import Optional @@ -21,6 +23,7 @@ from pricewatch.app.core.schema import ( StockStatus, ) from pricewatch.app.stores.base import BaseStore +from pricewatch.app.stores.price_parser import parse_price_text logger = get_logger("stores.amazon") @@ -131,6 +134,8 @@ class AmazonStore(BaseStore): images = self._extract_images(soup, debug_info) category = self._extract_category(soup, debug_info) specs = self._extract_specs(soup, debug_info) + description = self._extract_description(soup, debug_info) + msrp = self._extract_msrp(soup, debug_info) reference = self.extract_reference(url) or self._extract_asin_from_html(soup) # Déterminer le statut final (ne pas écraser FAILED) @@ -150,8 +155,10 @@ class AmazonStore(BaseStore): stock_status=stock_status, reference=reference, category=category, + description=description, images=images, specs=specs, + msrp=msrp, debug=debug_info, ) @@ -195,6 +202,17 @@ class AmazonStore(BaseStore): debug.errors.append("Titre non trouvé") return None + def _extract_description(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: + """Extrait la description (meta tags).""" + meta = soup.find("meta", property="og:description") or soup.find( + "meta", attrs={"name": "description"} + ) + if meta: + description = meta.get("content", "").strip() + if description: + return description + return None + def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]: """Extrait le prix.""" selectors = self.get_selector("price", []) @@ -205,14 +223,9 @@ class AmazonStore(BaseStore): elements = soup.select(selector) for element in elements: text = element.get_text(strip=True) - # Extraire nombre (format: "299,99" ou "299.99") - match = re.search(r"(\d+)[.,](\d+)", text) - if match: - price_str = f"{match.group(1)}.{match.group(2)}" - try: - return float(price_str) - except ValueError: - continue + price = parse_price_text(text) + if price is not None: + return price # Fallback: chercher les spans séparés a-price-whole et a-price-fraction whole = soup.select_one("span.a-price-whole") @@ -220,15 +233,24 @@ class AmazonStore(BaseStore): if whole and fraction: whole_text = whole.get_text(strip=True) fraction_text = fraction.get_text(strip=True) - try: - price_str = f"{whole_text}.{fraction_text}" - return float(price_str) - except ValueError: - pass + price = parse_price_text(f"{whole_text}.{fraction_text}") + if price is not None: + return price debug.errors.append("Prix non trouvé") return None + def _extract_msrp(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]: + """Extrait le prix conseille.""" + strike = soup.select_one("span.priceBlockStrikePriceString") or soup.select_one( + "span.a-text-price span.a-offscreen" + ) + if strike: + price = parse_price_text(strike.get_text(strip=True)) + if price is not None: + return price + return None + def _extract_currency(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: """Extrait la devise.""" selectors = self.get_selector("currency", []) @@ -270,6 +292,7 @@ class AmazonStore(BaseStore): def _extract_images(self, soup: BeautifulSoup, debug: DebugInfo) -> list[str]: """Extrait les URLs d'images.""" images = [] + seen = set() selectors = self.get_selector("images", []) if isinstance(selectors, str): selectors = [selectors] @@ -278,19 +301,57 @@ class AmazonStore(BaseStore): elements = soup.select(selector) for element in elements: # Attribut src ou data-src - url = element.get("src") or element.get("data-src") + url = element.get("src") or element.get("data-src") or element.get("data-old-hires") if url and url.startswith("http"): - images.append(url) + if self._is_product_image(url) and url not in seen: + images.append(url) + seen.add(url) + dynamic = element.get("data-a-dynamic-image") + if dynamic: + urls = self._extract_dynamic_images(dynamic) + for dyn_url in urls: + if self._is_product_image(dyn_url) and dyn_url not in seen: + images.append(dyn_url) + seen.add(dyn_url) # Fallback: chercher tous les img tags si aucune image trouvée if not images: all_imgs = soup.find_all("img") for img in all_imgs: url = img.get("src") or img.get("data-src") - if url and url.startswith("http"): - images.append(url) + if url and url.startswith("http") and self._is_product_image(url): + if url not in seen: + images.append(url) + seen.add(url) - return list(set(images)) # Dédupliquer + return images + + def _extract_dynamic_images(self, raw: str) -> list[str]: + """Extrait les URLs du JSON data-a-dynamic-image.""" + try: + data = json.loads(unescape(raw)) + except (TypeError, json.JSONDecodeError): + return [] + + urls = [] + if isinstance(data, dict): + candidates = [] + for url, dims in data.items(): + if not isinstance(url, str) or not url.startswith("http"): + continue + size = dims[0] if isinstance(dims, list) and dims else 0 + candidates.append((size, url)) + candidates.sort(key=lambda item: item[0], reverse=True) + for _, url in candidates: + urls.append(url) + return urls + + def _is_product_image(self, url: str) -> bool: + """Filtre basique pour eviter les logos et sprites.""" + lowered = url.lower() + if "prime_logo" in lowered or "sprite" in lowered: + return False + return True def _extract_category(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: """Extrait la catégorie depuis les breadcrumbs.""" diff --git a/pricewatch/app/stores/backmarket/store.py b/pricewatch/app/stores/backmarket/store.py index a06a8bf..d48d6e6 100755 --- a/pricewatch/app/stores/backmarket/store.py +++ b/pricewatch/app/stores/backmarket/store.py @@ -23,6 +23,7 @@ from pricewatch.app.core.schema import ( StockStatus, ) from pricewatch.app.stores.base import BaseStore +from pricewatch.app.stores.price_parser import parse_price_text logger = get_logger("stores.backmarket") @@ -116,6 +117,8 @@ class BackmarketStore(BaseStore): images = json_ld_data.get("images") or self._extract_images(soup, debug_info) category = self._extract_category(soup, debug_info) specs = self._extract_specs(soup, debug_info) + description = self._extract_description(soup, debug_info) + msrp = self._extract_msrp(soup, debug_info) reference = self.extract_reference(url) # Spécifique Backmarket: condition (état du reconditionné) @@ -140,8 +143,10 @@ class BackmarketStore(BaseStore): stock_status=stock_status, reference=reference, category=category, + description=description, images=images, specs=specs, + msrp=msrp, debug=debug_info, ) @@ -213,6 +218,17 @@ class BackmarketStore(BaseStore): debug.errors.append("Titre non trouvé") return None + def _extract_description(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: + """Extrait la description (meta tags).""" + meta = soup.find("meta", property="og:description") or soup.find( + "meta", attrs={"name": "description"} + ) + if meta: + description = meta.get("content", "").strip() + if description: + return description + return None + def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]: """Extrait le prix.""" selectors = self.get_selector("price", []) @@ -225,20 +241,29 @@ class BackmarketStore(BaseStore): # Attribut content (schema.org) ou texte price_text = element.get("content") or element.get_text(strip=True) - # Extraire nombre (format: "299,99" ou "299.99" ou "299") - match = re.search(r"(\d+)[.,]?(\d*)", price_text) - if match: - integer_part = match.group(1) - decimal_part = match.group(2) or "00" - price_str = f"{integer_part}.{decimal_part}" - try: - return float(price_str) - except ValueError: - continue + price = parse_price_text(price_text) + if price is not None: + return price debug.errors.append("Prix non trouvé") return None + def _extract_msrp(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]: + """Extrait le prix conseille.""" + selectors = [ + ".price--old", + ".price--striked", + ".price__old", + "del", + ] + for selector in selectors: + element = soup.select_one(selector) + if element: + price = parse_price_text(element.get_text(strip=True)) + if price is not None: + return price + return None + def _extract_currency(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: """Extrait la devise.""" selectors = self.get_selector("currency", []) diff --git a/pricewatch/app/stores/cdiscount/store.py b/pricewatch/app/stores/cdiscount/store.py index 0de9f5e..be8bdc9 100755 --- a/pricewatch/app/stores/cdiscount/store.py +++ b/pricewatch/app/stores/cdiscount/store.py @@ -4,6 +4,7 @@ Store Cdiscount - Parsing de produits Cdiscount.com. Supporte l'extraction de: titre, prix, SKU, images, specs, etc. """ +import json import re from datetime import datetime from pathlib import Path @@ -21,6 +22,7 @@ from pricewatch.app.core.schema import ( StockStatus, ) from pricewatch.app.stores.base import BaseStore +from pricewatch.app.stores.price_parser import parse_price_text logger = get_logger("stores.cdiscount") @@ -112,6 +114,8 @@ class CdiscountStore(BaseStore): images = self._extract_images(soup, debug_info) category = self._extract_category(soup, debug_info) specs = self._extract_specs(soup, debug_info) + description = self._extract_description(soup, debug_info) + msrp = self._extract_msrp(soup, debug_info) reference = self.extract_reference(url) or self._extract_sku_from_html(soup) # Déterminer le statut final @@ -130,8 +134,10 @@ class CdiscountStore(BaseStore): stock_status=stock_status, reference=reference, category=category, + description=description, images=images, specs=specs, + msrp=msrp, debug=debug_info, ) @@ -158,6 +164,21 @@ class CdiscountStore(BaseStore): debug.errors.append("Titre non trouvé") return None + def _extract_description(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: + """Extrait la description (meta tags).""" + meta = soup.find("meta", property="og:description") or soup.find( + "meta", attrs={"name": "description"} + ) + if meta: + description = meta.get("content", "").strip() + if description: + return description + product_ld = self._find_product_ld(soup) + desc_ld = product_ld.get("description") if product_ld else None + if isinstance(desc_ld, str) and desc_ld.strip(): + return desc_ld.strip() + return None + def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]: """Extrait le prix.""" selectors = self.get_selector("price", []) @@ -170,20 +191,29 @@ class CdiscountStore(BaseStore): # Attribut content (schema.org) ou texte price_text = element.get("content") or element.get_text(strip=True) - # Extraire nombre (format: "299,99" ou "299.99") - match = re.search(r"(\d+)[.,]?(\d*)", price_text) - if match: - integer_part = match.group(1) - decimal_part = match.group(2) or "00" - price_str = f"{integer_part}.{decimal_part}" - try: - return float(price_str) - except ValueError: - continue + price = parse_price_text(price_text) + if price is not None: + return price debug.errors.append("Prix non trouvé") return None + def _extract_msrp(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]: + """Extrait le prix conseille.""" + selectors = [ + ".jsStrikePrice", + ".price__old", + ".c-price__strike", + ".price-strike", + ] + for selector in selectors: + element = soup.select_one(selector) + if element: + price = parse_price_text(element.get_text(strip=True)) + if price is not None: + return price + return None + def _extract_currency(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: """Extrait la devise.""" selectors = self.get_selector("currency", []) @@ -249,7 +279,14 @@ class CdiscountStore(BaseStore): url = f"https:{url}" images.append(url) - return list(set(images)) # Dédupliquer + ld_images = self._extract_ld_images(self._find_product_ld(soup)) + for url in ld_images: + if url and url not in images: + if url.startswith("//"): + url = f"https:{url}" + images.append(url) + + return list(dict.fromkeys(images)) # Préserver l’ordre def _extract_category(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]: """Extrait la catégorie depuis les breadcrumbs.""" @@ -275,6 +312,53 @@ class CdiscountStore(BaseStore): return None + def _extract_json_ld_entries(self, soup: BeautifulSoup) -> list[dict]: + """Parse les scripts JSON-LD et retourne les objets.""" + entries = [] + scripts = soup.find_all("script", type="application/ld+json") + for script in scripts: + raw = script.string or script.text + if not raw: + continue + try: + payload = json.loads(raw.strip()) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(payload, list): + entries.extend(payload) + else: + entries.append(payload) + return entries + + def _find_product_ld(self, soup: BeautifulSoup) -> dict: + """Retourne l’objet Product JSON-LD si présent.""" + for entry in self._extract_json_ld_entries(soup): + if not isinstance(entry, dict): + continue + type_field = entry.get("@type") or entry.get("type") + if isinstance(type_field, str) and "product" in type_field.lower(): + return entry + return {} + + def _extract_ld_images(self, product_ld: dict) -> list[str]: + """Récupère les images listées dans le JSON-LD.""" + if not product_ld: + return [] + images = product_ld.get("image") or product_ld.get("images") + if not images: + return [] + if isinstance(images, str): + images = [images] + extracted = [] + for item in images: + if isinstance(item, str): + extracted.append(item) + elif isinstance(item, dict): + url = item.get("url") + if isinstance(url, str): + extracted.append(url) + return extracted + def _extract_specs(self, soup: BeautifulSoup, debug: DebugInfo) -> dict[str, str]: """Extrait les caractéristiques techniques.""" specs = {} @@ -298,6 +382,19 @@ class CdiscountStore(BaseStore): if key and value: specs[key] = value + product_ld = self._find_product_ld(soup) + additional = product_ld.get("additionalProperty") if product_ld else None + if isinstance(additional, dict): + additional = [additional] + if isinstance(additional, list): + for item in additional: + if not isinstance(item, dict): + continue + key = item.get("name") or item.get("propertyID") + value = item.get("value") or item.get("valueReference") + if key and value: + specs[key] = value + return specs def _extract_sku_from_html(self, soup: BeautifulSoup) -> Optional[str]: diff --git a/pricewatch/app/stores/price_parser.py b/pricewatch/app/stores/price_parser.py new file mode 100644 index 0000000..2947944 --- /dev/null +++ b/pricewatch/app/stores/price_parser.py @@ -0,0 +1,48 @@ +""" +Helpers pour parser des prix avec separateurs de milliers. +""" + +from __future__ import annotations + +import re +from typing import Optional + + +def parse_price_text(text: str) -> Optional[float]: + """ + Parse un texte de prix en float. + + Gere les separateurs espace, point, virgule et espaces insécables. + """ + if not text: + return None + + text = re.sub(r"(\d)\s*€\s*(\d)", r"\1,\2", text) + cleaned = text.replace("\u00a0", " ").replace("\u202f", " ").replace("\u2009", " ") + cleaned = "".join(ch for ch in cleaned if ch.isdigit() or ch in ".,") + if not cleaned: + return None + + if "," in cleaned and "." in cleaned: + if cleaned.rfind(",") > cleaned.rfind("."): + cleaned = cleaned.replace(".", "") + cleaned = cleaned.replace(",", ".") + else: + cleaned = cleaned.replace(",", "") + elif "," in cleaned: + parts = cleaned.split(",") + if len(parts) > 1: + decimal = parts[-1] + integer = "".join(parts[:-1]) + cleaned = f"{integer}.{decimal}" if decimal else integer + elif "." in cleaned: + parts = cleaned.split(".") + if len(parts) > 1: + decimal = parts[-1] + integer = "".join(parts[:-1]) + cleaned = f"{integer}.{decimal}" if decimal else integer + + try: + return float(cleaned) + except ValueError: + return None diff --git a/pricewatch/app/tasks/__init__.py b/pricewatch/app/tasks/__init__.py old mode 100755 new mode 100644 index 7ffa53d..9651355 --- a/pricewatch/app/tasks/__init__.py +++ b/pricewatch/app/tasks/__init__.py @@ -3,6 +3,15 @@ Module tasks pour les jobs RQ. """ from pricewatch.app.tasks.scrape import scrape_product -from pricewatch.app.tasks.scheduler import ScrapingScheduler +from pricewatch.app.tasks.scheduler import ( + RedisUnavailableError, + ScrapingScheduler, + check_redis_connection, +) -__all__ = ["scrape_product", "ScrapingScheduler"] +__all__ = [ + "scrape_product", + "ScrapingScheduler", + "RedisUnavailableError", + "check_redis_connection", +] diff --git a/pricewatch/app/tasks/__pycache__/__init__.cpython-313.pyc b/pricewatch/app/tasks/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d485267bcae2bd83602e487782e35814e8799cd GIT binary patch literal 471 zcmZ`$u};G<5Vf78tx8*o0Tv`=v1LivSSmJ_AduRqvz5A5>NZWHOA3OGnVFB^m%K8u zQ7XjBIc-O7czP#2zjt@u_WNCeb@7_&2_@viHiu$+vb{v|Mq&aarZKyv5d+38cW#}? z1vm1*i(1g)BqXsrqR>t~zxB+!cg1X-DPAYlvf^d22A-*kFN$f!$D^S8X#;#}29<=Q zRH6jb%i4F$$ym*FHOZ5QL}$q~Q#SwwnB&+Ab)Lp@uB_%xGoAZAZ`|O=>lVHuQbCP93 zypt`q#0PNm51a5%2%YO%h@gBjo#Cpv-Zc!+4F85y g9O28Mlx`TK?oWr%t6%0)wjl`LJ@1IUw4sNhKjU|Wr~m)} literal 0 HcmV?d00001 diff --git a/pricewatch/app/tasks/__pycache__/scheduler.cpython-313.pyc b/pricewatch/app/tasks/__pycache__/scheduler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0898ecf8bc43137d5a914147ba3a51b6b80ca27 GIT binary patch literal 7435 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 literal 0 HcmV?d00001 diff --git a/pricewatch/app/tasks/scheduler.py b/pricewatch/app/tasks/scheduler.py old mode 100755 new mode 100644 index 628594c..cb11883 --- a/pricewatch/app/tasks/scheduler.py +++ b/pricewatch/app/tasks/scheduler.py @@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone from typing import Optional import redis +from redis.exceptions import ConnectionError as RedisConnectionError +from redis.exceptions import RedisError, TimeoutError as RedisTimeoutError from rq import Queue from rq_scheduler import Scheduler @@ -19,6 +21,15 @@ from pricewatch.app.tasks.scrape import scrape_product logger = get_logger("tasks.scheduler") +class RedisUnavailableError(Exception): + """Exception levee quand Redis n'est pas disponible.""" + + def __init__(self, message: str = "Redis non disponible", cause: Optional[Exception] = None): + self.message = message + self.cause = cause + super().__init__(self.message) + + @dataclass class ScheduledJobInfo: """Infos de retour pour un job planifie.""" @@ -27,14 +38,72 @@ class ScheduledJobInfo: next_run: datetime +def check_redis_connection(redis_url: str) -> bool: + """ + Verifie si Redis est accessible. + + Returns: + True si Redis repond, False sinon. + """ + try: + conn = redis.from_url(redis_url) + conn.ping() + return True + except (RedisConnectionError, RedisTimeoutError, RedisError) as e: + logger.debug(f"Redis ping echoue: {e}") + return False + + class ScrapingScheduler: """Scheduler pour les jobs de scraping avec RQ.""" def __init__(self, config: Optional[AppConfig] = None, queue_name: str = "default") -> None: self.config = config or get_config() - self.redis = redis.from_url(self.config.redis.url) - self.queue = Queue(queue_name, connection=self.redis) - self.scheduler = Scheduler(queue=self.queue, connection=self.redis) + self._queue_name = queue_name + self._redis: Optional[redis.Redis] = None + self._queue: Optional[Queue] = None + self._scheduler: Optional[Scheduler] = None + + def _ensure_connected(self) -> None: + """Etablit la connexion Redis si necessaire, leve RedisUnavailableError si echec.""" + if self._redis is not None: + return + + try: + self._redis = redis.from_url(self.config.redis.url) + # Ping pour verifier la connexion + self._redis.ping() + self._queue = Queue(self._queue_name, connection=self._redis) + self._scheduler = Scheduler(queue=self._queue, connection=self._redis) + logger.debug(f"Connexion Redis etablie: {self.config.redis.url}") + except (RedisConnectionError, RedisTimeoutError) as e: + self._redis = None + msg = f"Impossible de se connecter a Redis ({self.config.redis.url}): {e}" + logger.error(msg) + raise RedisUnavailableError(msg, cause=e) from e + except RedisError as e: + self._redis = None + msg = f"Erreur Redis: {e}" + logger.error(msg) + raise RedisUnavailableError(msg, cause=e) from e + + @property + def redis(self) -> redis.Redis: + """Acces a la connexion Redis (lazy).""" + self._ensure_connected() + return self._redis # type: ignore + + @property + def queue(self) -> Queue: + """Acces a la queue RQ (lazy).""" + self._ensure_connected() + return self._queue # type: ignore + + @property + def scheduler(self) -> Scheduler: + """Acces au scheduler RQ (lazy).""" + self._ensure_connected() + return self._scheduler # type: ignore def enqueue_immediate( self, diff --git a/pricewatch/app/tasks/scrape.py b/pricewatch/app/tasks/scrape.py old mode 100755 new mode 100644 index 3db721a..44486d6 --- a/pricewatch/app/tasks/scrape.py +++ b/pricewatch/app/tasks/scrape.py @@ -4,6 +4,7 @@ Tache de scraping asynchrone pour RQ. from __future__ import annotations +import time from typing import Any, Optional from pricewatch.app.core.config import AppConfig, get_config @@ -46,6 +47,9 @@ def scrape_product( Retourne un dict avec success, product_id, snapshot, error. """ + job_start_time = time.time() + logger.info(f"[JOB START] Scraping: {url}") + config: AppConfig = get_config() setup_stores() @@ -58,6 +62,8 @@ def scrape_product( registry = get_registry() store = registry.detect_store(url) if not store: + elapsed_ms = int((time.time() - job_start_time) * 1000) + logger.error(f"[JOB FAILED] Aucun store detecte pour: {url} (duree={elapsed_ms}ms)") snapshot = ProductSnapshot( source="unknown", url=url, @@ -70,6 +76,8 @@ def scrape_product( ScrapingPipeline(config=config).process_snapshot(snapshot, save_to_db=save_db) return {"success": False, "product_id": None, "snapshot": snapshot, "error": "store"} + logger.info(f"[STORE] Detecte: {store.store_id}") + canonical_url = store.canonicalize(url) html = None @@ -79,13 +87,16 @@ def scrape_product( html_size_bytes = None pw_result = None + logger.debug(f"[FETCH] Tentative HTTP: {canonical_url}") http_result = fetch_http(canonical_url) duration_ms = http_result.duration_ms if http_result.success: html = http_result.html fetch_method = FetchMethod.HTTP + logger.info(f"[FETCH] HTTP OK (duree={duration_ms}ms, taille={len(html)})") elif use_playwright: + logger.debug(f"[FETCH] HTTP echoue ({http_result.error}), fallback Playwright") pw_result = fetch_playwright( canonical_url, headless=not headful, @@ -97,10 +108,13 @@ def scrape_product( if pw_result.success: html = pw_result.html fetch_method = FetchMethod.PLAYWRIGHT + logger.info(f"[FETCH] Playwright OK (duree={duration_ms}ms, taille={len(html)})") else: fetch_error = pw_result.error + logger.warning(f"[FETCH] Playwright echoue: {fetch_error}") else: fetch_error = http_result.error + logger.warning(f"[FETCH] HTTP echoue: {fetch_error}") if html: html_size_bytes = len(html.encode("utf-8")) @@ -118,12 +132,18 @@ def scrape_product( save_debug_screenshot(pw_result.screenshot, f"{store.store_id}_{ref}") try: + logger.debug(f"[PARSE] Parsing avec {store.store_id}...") snapshot = store.parse(html, canonical_url) snapshot.debug.method = fetch_method snapshot.debug.duration_ms = duration_ms snapshot.debug.html_size_bytes = html_size_bytes success = snapshot.debug.status != DebugStatus.FAILED + if success: + logger.info(f"[PARSE] OK - titre={bool(snapshot.title)}, prix={snapshot.price}") + else: + logger.warning(f"[PARSE] Partiel - status={snapshot.debug.status}") except Exception as exc: + logger.error(f"[PARSE] Exception: {exc}") snapshot = ProductSnapshot( source=store.store_id, url=canonical_url, @@ -152,6 +172,19 @@ def scrape_product( product_id = ScrapingPipeline(config=config).process_snapshot(snapshot, save_to_db=save_db) + # Log final du job + elapsed_ms = int((time.time() - job_start_time) * 1000) + if success: + logger.info( + f"[JOB OK] {store.store_id}/{snapshot.reference} " + f"product_id={product_id} prix={snapshot.price} duree={elapsed_ms}ms" + ) + else: + logger.warning( + f"[JOB FAILED] {store.store_id}/{snapshot.reference or 'unknown'} " + f"erreur={fetch_error} duree={elapsed_ms}ms" + ) + return { "success": success, "product_id": product_id, diff --git a/pyproject.toml b/pyproject.toml index 4697124..39f9c59 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,10 @@ dependencies = [ "redis>=5.0.0", "rq>=1.15.0", "rq-scheduler>=0.13.0", + + # API (Phase 3) + "fastapi>=0.110.0", + "uvicorn>=0.27.0", ] [project.optional-dependencies] diff --git a/scrap_url.yaml b/scrap_url.yaml index a73e808..de96eb9 100755 --- a/scrap_url.yaml +++ b/scrap_url.yaml @@ -4,7 +4,8 @@ # Liste des URLs à scraper # Note: Ces URLs sont des exemples, remplacez-les par de vraies URLs produit urls: - - "https://www.amazon.fr/NINJA-Essential-Cappuccino-préréglages-ES501EU/dp/B0DFWRHZ7L" + - "https://www.amazon.fr/ASUS-A16-TUF608UH-RV054W-Portable-Processeur-Windows/dp/B0DQ8M74KL" + - "https://www.cdiscount.com/informatique/ordinateurs-pc-portables/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo/f-10709-tuf608umrv004.html" # Options de scraping options: @@ -23,3 +24,4 @@ options: # Timeout par page en millisecondes timeout_ms: 60000 + force_playwright: true diff --git a/scraped_store.json b/scraped_store.json old mode 100755 new mode 100644 index e416b34..c7c4692 --- a/scraped_store.json +++ b/scraped_store.json @@ -1,28 +1,121 @@ [ { "source": "amazon", - "url": "https://www.amazon.fr/dp/B0DFWRHZ7L", - "fetched_at": "2026-01-13T13:24:21.615894", - "title": null, - "price": null, + "url": "https://www.amazon.fr/dp/B0DQ8M74KL", + "fetched_at": "2026-01-14T21:33:15.838503", + "title": "ASUS TUF Gaming A16-TUF608UH-RV054W 16 Pouces FHD Plus 165Hz Pc Portable (Processeur AMD Ryzen 7 260, 16GB DDR5, 512GB SSD, NVIDIA RTX 5050) Windows 11 Home – Clavier AZERTY", + "price": 1259.0, + "msrp": 1699.99, + "currency": "EUR", + "shipping_cost": null, + "stock_status": "in_stock", + "reference": "B0DQ8M74KL", + "category": "Ordinateurs portables classiques", + "description": "ASUS TUF Gaming A16-TUF608UH-RV054W 16 Pouces FHD Plus 165Hz Pc Portable (Processeur AMD Ryzen 7 260, 16GB DDR5, 512GB SSD, NVIDIA RTX 5050) Windows 11 Home – Clavier AZERTY : Amazon.fr: Informatique", + "images": [ + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SY300_SX300_QL70_ML2_.jpg", + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SX679_.jpg", + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SX569_.jpg", + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SX522_.jpg", + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SX466_.jpg", + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SY450_.jpg", + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SX425_.jpg", + "https://m.media-amazon.com/images/I/713fTyxvEWL._AC_SY355_.jpg" + ], + "specs": { + "Marque": "‎ASUS", + "Numéro du modèle de l'article": "‎90NR0KS1-M00480", + "séries": "‎ASUS TUF Gaming", + "Couleur": "‎GRAY", + "Garantie constructeur": "‎3 ans contructeur", + "Système d'exploitation": "‎Windows 11 Home", + "Description du clavier": "‎Jeu", + "Marque du processeur": "‎AMD", + "Type de processeur": "‎Ryzen 7", + "Vitesse du processeur": "‎3,8 GHz", + "Nombre de coeurs": "‎8", + "Mémoire maximale": "‎32 Go", + "Taille du disque dur": "‎512 GB", + "Technologie du disque dur": "‎SSD", + "Interface du disque dur": "‎PCIE x 4", + "Type d'écran": "‎LED", + "Taille de l'écran": "‎16 Pouces", + "Résolution de l'écran": "‎1920 x 1200 pixels", + "Resolution": "‎1920x1200 Pixels", + "Marque chipset graphique": "‎NVIDIA", + "Description de la carte graphique": "‎NVIDIA GeForce RTX 5050 Laptop GPU - 8GB GDDR7", + "GPU": "‎NVIDIA GeForce RTX 5050 Laptop GPU - 8GB GDDR7", + "Mémoire vive de la carte graphique": "‎8 GB", + "Type de mémoire vive (carte graphique)": "‎GDDR7", + "Type de connectivité": "‎Bluetooth, Wi-Fi", + "Type de technologie sans fil": "‎802.11ax, Bluetooth", + "Bluetooth": "‎Oui", + "Nombre de ports HDMI": "‎1", + "Nombre de ports USB 2.0": "‎1", + "Nombre de ports USB 3.0": "‎3", + "Nombre de ports Ethernet": "‎1", + "Type de connecteur": "‎Bluetooth, HDMI, USB, Wi-Fi", + "Compatibilité du périphérique": "‎Casque audio, Clavier, Souris, Ecran externe, Disque dur externe, Imprimante, etc., Haut-parleur", + "Poids du produit": "‎2,1 Kilogrammes", + "Divers": "‎Clavier rétroéclairé", + "Disponibilité des pièces détachées": "‎5 Ans", + "Mises à jour logicielles garanties jusqu’à": "‎Information non disponible", + "ASIN": "B0DQ8M74KL", + "Moyenne des commentaires client": "4,74,7 sur 5 étoiles(7)4,7 sur 5 étoiles", + "Classement des meilleures ventes d'Amazon": "5 025 en Informatique (Voir les 100 premiers en Informatique)124 enOrdinateurs portables classiques", + "Date de mise en ligne sur Amazon.fr": "1 juillet 2025" + }, + "debug": { + "method": "playwright", + "status": "success", + "errors": [], + "notes": [], + "duration_ms": null, + "html_size_bytes": null + } + }, + { + "source": "cdiscount", + "url": "https://www.cdiscount.com/informatique/ordinateurs-pc-portables/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo/f-10709-tuf608umrv004.html", + "fetched_at": "2026-01-14T21:33:20.309754", + "title": "PC Portable Gamer ASUS TUF Gaming A16 | Sans Windows - 16\" WUXGA 165Hz - RTX 5060 8Go - AMD Ryzen 7 260 - RAM 16Go - 1To SSD", + "price": 119999.0, + "msrp": null, "currency": "EUR", "shipping_cost": null, "stock_status": "unknown", - "reference": "B0DFWRHZ7L", + "reference": "10709-tuf608umrv004", "category": null, - "images": [], + "description": "Cdiscount : Meuble, Déco, High Tech, Bricolage, Jardin, Sport | Livraison gratuite à partir de 10€ | Paiement sécurisé | 4x possible | Retour simple et rapide | E-commerçant français, des produits et services au meilleur prix.", + "images": [ + "https://www.cdiscount.com/pdt2/0/0/4/1/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/2/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/3/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/4/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/5/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/6/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/7/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/8/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/9/700x700/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/1/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/2/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/3/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/4/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/5/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/6/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/7/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/8/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/pdt2/0/0/4/9/115x115/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg", + "https://www.cdiscount.com/ac/085x085/TUF608UMRV004_177763282_1.png", + "https://www.cdiscount.com/ac/085x085/TUF608UMRV004_177763282_2.png", + "https://www.cdiscount.com/pdt2/0/0/4/9/550x550/tuf608umrv004/rw/pc-portable-gamer-asus-tuf-gaming-a16-sans-windo.jpg" + ], "specs": {}, "debug": { - "method": "http", - "status": "partial", - "errors": [ - "Captcha ou robot check détecté", - "Titre non trouvé", - "Prix non trouvé" - ], - "notes": [ - "Parsing incomplet: titre ou prix manquant" - ], + "method": "playwright", + "status": "success", + "errors": [], + "notes": [], "duration_ms": null, "html_size_bytes": null } diff --git a/tests/api/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad1e110ec29c965055d806d53fdd653d8f9d0796 GIT binary patch literal 4369 zcmds4&2JmW6`%bex#W`6mtDz>?W|NOF)M|nEXj&gQ?Q%ZbtQxev=Ul?LKiD?No`E- zu4k8xr6Pb2Mf!0`i`M^xdGE13t-@S$iGks8_2A6{tS9vYa2U>YiR{ z=&lPw>dM;MJD09k497EV3l-G3)-tJ~dG=Mqs&u1c51}h`6ku+W4MI7{4djFh6J$f& zh^YLlU{M7ct&7=+-*;KRYOESn(|yCXYG(cOBv^f+MH_8WYR7y@(=5GdXqunUw5DBc zHIPqg+O?M6=vw+Tt!7f!YnYZ{*gVaDIZ$P;h|+$c!4X2mm7|$$}hl0RU->RQ(7mhILeWxM%SfTPK{;AyHa@R z6jGK#w^dxkkv2xkmwr~)>J9fLwS(!@A23_)=pR|v7!&vdQTzY$W#)56*+rt?UH5|mhvz`Eq0&Cc?3VqjL2<&n^F6;phqjghca z{SYkGm!6pAAZydy1i_n#zf< zMG3K@YFh1Ej}|y4#mb{$o1 z8n2L-i2{E==1TFR@G_}!+5Vjqs&2UoE6Y;~H9WgTEkl_tOy>>;-D?fgGZypt{9@B+ z+H`xd&9P!~Fv4ytSQ>UkZ*1AFw|H)5ej#7*qpnebVcG)4-J_?FV2%&Q9BNjK_jIqa zmD3#uerB$zn^vxFcv^*(@+XQ7giP?lb|fH6+)c7Z#z~tiu`Ax@{Akd}MZX&qH`JHT zH}vLaRbTQ8SbVjn>AI#>59)~qQNh|=4cB&_#qBNu6Z9CzneZ0>`v&=_#wtR|`~!^L z<_`E`vCZw2CeAq9vspLs%Pyl7739u_Dqy;*Le|awLT|K;#QQk}kd_Mztb(KzgFf?F z*N++3E2dSm+oCTx+XW${aTp$GFza9h~2-5|cg)v>kJ*C?&1V`lb ziw_1ZK^Hu;?k7OtEPR^EJd}rbPZsv%>4)i4yJP3~(!~dZnJ;2uI(p;seu6~$?~B8G z;_&Xs`M+3q#J4_;_kDQkcc=dJ{+;;5t>K5#@!wbPNH0ASNaB_KC`k@|Sp02qSDd^r zkMGIjz}ylT*^?*lgO>gOU7XyN#}CDTxhqcoX8usO_gF$hF)-Zj=?MHS;n4q(xhGCO zia{**;u8lQgp`luIt%3H*zHE&LnUIk|>kB#LQ4nKY|LF9goyr~(&+jv_%Err$?`P(dev zd^KFrSJ9WflOh!NU={rV$o>?oMw@!|DTtbR8lq-!tSKZ2i#zg<45-yqv24dEnSTVR zqOYMbj|599oknsF$vl$Rkz5Ayth$(aek4?dJ~;2USreOgr19@$O{8zbO0z?LOw+1% z1wI0$5D4i7u=ppm06KF4RsqoEsPP_!_ayjs39-+Qb#-(J`^bmi#(>NMXy_uUV>KN> z*)bVd`@_$9KVm2zd^NJK9(;*rFva%e$OjL+0tKJEOLPPL@ZHFzK=uWW5Sb6jd4Y^ja_;yx3l7aIg_XrBP-_5kk7WRjceoCd-U G>3;x9zhsU8 literal 0 HcmV?d00001 diff --git a/tests/api/__pycache__/test_backend_logs.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_backend_logs.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..003a0911c09e53673ffcd574009d8ee2ac927b16 GIT binary patch literal 2807 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 literal 0 HcmV?d00001 diff --git a/tests/api/__pycache__/test_health.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_health.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df49b52ae74353ef0033ca4aec68475fdf9fc09d GIT binary patch literal 3614 zcmeHK&2Q936d!-=NA@e^gXW{5aRan06}urM(4=NQC9xbv7nm+l;-U z*{V_w6$SNBsZu1={~-TD)k|+ZCL|G&#i3H^fm=`x^w{^t_T~dZE1}*x+I@a+-t)|F z-p9;%C!J0ZXuoZi^&bU7{y-sYk$~Cx37BOf6G~)G=C5;xikaOZQ0=Q!0wmh&1)s;d?a)p0;b`D&#&uN4=H0UGuI zAF#F~!DE@s63W54VZBs1NoK{_h|GTo8d;#xvY3r{jbrM9HmQ|#=dxwa>E-9>PEV*( zV}(qt#Jsqom}*5+6fdDD6{}P=kWVSf-KuH?nnp#L)2ZVcx~Z8Ks0GKR9`vfFX}PMa zTc!iOM-|0Esi>%~OZ7t4)u5X;z=S*SCtiPk-l}N%vThifleejLM=QEc-YHVm&Vw=V zs;x6l3H)O((kQ&j2qNE!0r`&n-jKR~jW&mxxAIU5J#?iIxTg`vj3YtZ6BH+9tUp@| zitmJk8MmUupkV*&q4b01@LzCYpHqg9au5;hDCrsqzVNXYHjGMTAsXTr2)1v<#hgry6KD)WP!EXt8V zp%R-WlQ_zOCP$}Z{hSO%c&A>35;?>+E<-tn#dxSy0*iCkY6jiFT3GrfH_c75ttZs@ zPm`BZR^PPn8L_UM3RI<^37J8Mvo?tiKhR)mebUPe4<8ci$`+paZRkOmhQ)&qHh~M1Bu8%!31M?^|DRfN6InueavIIsVa72rcC! zIpGXso*9vwn4d&cSv`*xpzF&d5)J~d8m8R1NRgtIS0th#osL+&kO z)%t&|{)g$Lo@BxKrC1BOVTRKg1;)4N0ec5Gz`vHruXAiOt$lPDLUsdCN(F>z!f^>_?Y>R7 z4i)#5Wm4u=_=%|yr%;?LGmsJkNsogKJplw624RV;$~Sxnxi>>#UjNr9=m$6zJ6yVP zE`)gy1Of>`e0ht3m;&eWI^2M2r%#@;7yGhK;_HBsMkM5Vvrev5NPHvd4wDD6ju)kx zQ#By&o&W|&`j8CNGH{#HsLKp5fFX2VfV)=h%o_%0>)bbfwvnBP{uzqLrx$ZHxahc) zqSINjd5MemA`84qy(R?`(@>~p)1p81O)ynr55#ClsSu5R9DfyvJKT_*Dsdq*|?XiSmuJZXk(_+ z(+D+*6Z>vByI1JnE1dHMf86tXg^wxAR;&ZbZx{W1&+N}~Itmt8b3^g60(h6Oi@Z!5iZv8FmY%fhO|TRQ!VF*6%r#ywR;q{OSlb&x#GowKH7`# zBnr)+f0nVSs`#O+XP7P26Cx1Cow1e4T7cC*8l(j literal 0 HcmV?d00001 diff --git a/tests/api/__pycache__/test_http_integration.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_http_integration.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..303a9d1b8c73733ad5fc8190fab2910d6938e587 GIT binary patch literal 6960 zcmeHLU2I&%6`uREd-u<~j(^B6_+pHc#lh?KPi%)cU`PlA#h|^}2IQ2h>)mVn;`P1T zxz{AwDpL9*VIXacT7(f&%LA&Q?E^3EL;KM3hN@OtV}i{sQmJ_2%_vBb(wCld|900n zGNGy3N*#M=&N*}D+?g|HX1RTr5vPpO=vg7x<{PY)okZd!eu#A?G=5X9kQ5HI5|VH> zn%h&e;!}gH6xlv*Xirj%G|v*+NaJocakBGIpCE&M2boAQC3Rq z3F>I;ZL--^XR~dFO(!dv*lE?YO!2YPr=Jpw`!(HCXQ*OnhAtKi-J+^k+CTo(MZ1 z6Frn>?Z}j3s`3IY*@C6btHy$r=Iju5^t>H@q@=03Wk>uGyhE;GQ~PLU)|gi_Gg_&n znwc^+o>L2!nK27gDQ7J7Q${Ik4ke>0xz1g^=M~vL!tIhn%XWuv)sMjy8E@muN+?1 zR$C7&9=pzwS2td6Tj*Yi+@3k8U1+M!j63rtMR;?dky#756rL1!HLmFfH2uCQcQmxZrLrm2 zw|bI+J_P}o+gh9T!+%>%&exQLUhaOdtB0hB6wQbF7>5f_Vz4=>&eoPkNj~h`@=F!A zqA^B?U-^+dliTTAs+E2O=t73T%*BkwynvoG28^Es{(<It%n&0cueAKTy!4$0q z?`=J^i9820+w}AcwVACO&DK7Q_@j`Lw><^~-?l%FuN4D9D~N%lll(wmQT2`ZhWw%@0$Fv zi1jLv!OHs8414oK z>jXRTQ}0;XRk_nUl|-t-hFuhu=hcE(oXSuYw(wrs0#P$Tak;<4++fGX-eD>O_Z}!; z=uexm?|OtdCL-0BHhb&NvDig%%&izon{*di;A{@mFzd%`=tfd8O_f?s^Wn6K!eN^& zPT3*n^Kf*qnx1AVEogrrZNkCg=z>LCC@U9AhEjw>5}FNAM3WT(6~Pj;X+ts#o$?!H zq0gYqqtwa!D-lO$5UinekmC7TN!4{Ki@n0S;dIhDuPN?wv>h@#_E7l(!W~(m_6`|9 zT}cKX(t8oc(-C=tl19Xd@c5P+{8n~eb?ob0a zV&d*AZb~lfX>(leA}JY0UN!(;(nmpmANhANQyG zVkbw10~SA9Ssn5%L~VfT+N+&*-6&K*SOf0JNie6O3 z1zog^IaL>*S4vt@?X5%u9qkqH6~(dByD-CbMK0Ki|c{#ssMMpA_U+rqR#@W;pK7CSrBCwdV(nBabiP;m7rDHYpOGi<97&ClBphqwp!|W(z z_1JaTWn8p6Y9e~6Ay5~5?sLGWKZk(fzq6ph!<_8QAk9IGZupsh-x`GWtfsmb`E~#G z4IbX!9B?vtgl{J%jq|IptxLF?s>~vO&tXKqtwB*NW=dM zDg?4d&J}+Imb&USeC{fK!mU^ZvM11Si4P+O-vY;aUjxfN2koj~=?7?EZvxVhf1cuZ>64ft_;z2BdA#;H&%ic6uh2O=Y|d$AZQ8jQ zIe|%=P#F(PHdP=+uq?x0j}V6WA^Ab{Gt%@|lGuQ^ z;pCOkmqwSqUF~{cHGbq$WE}*Lzx4Q${^s0jqW@BKBg`-(OYN`idu893&~{`a5@y<$ zj(tg>*k~baXc7Rz1Sp M@2Y>n0_lwFpY6!Hd;kCd literal 0 HcmV?d00001 diff --git a/tests/api/__pycache__/test_products.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_products.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f5618b76afc948758553ba53cb1c92ed3fbbbf6f GIT binary patch literal 3130 zcmcIm&2JM&6yNo(zhj$xJ0Ybeg}|!9PJj@SHngAyTBm$@aaGBO)yAGA+pc$)nYAFP zDg_A%P-bsltZPS`eyC%I+PN%QdjoO?|sa?H*e>? zx4Vf%On~Rv)|~N^BnZDTfP(?K`E#iPTOL>_DGD1FxLm4KKro;I# z4trRhj^v{_>fzvYEFZ^l4~M4P@(G;qaCkbIPvKPl06qY8q+h`8a{(o~I25~qh)Xi( zuYHnnuwAJcKp-n)qOx1VC__-qMA_JL=H3-Wq>+m5BG;%Qz=<+OKxxRDGc3kvBZv^g zwyOG<2r~uKAg<-qERR$mp>PPOY_d3kV;o!5F;63PCo^gl&1+Ki@HQ2Y~d#fHW%hZJ4F_Prtkxb5@07^A%(x7X&CZdrtw17GA z0ZKyxJgNuwWGgY+ zN}R3}b%SHA#CWUPFk#!L0fNuL*6e9F$`N&SP zeqjMS7+7^uU+3vkG*V@pZXxS@ebn=bp%Y_1~u4cYnM|)xnnB_wA`BZ(|QtXa5YV+*S=v z!BV~*?7S>Cb(#R!+ws&x@xj=-JoF?!un`{suoCiyjrhgkcK z-#ECkkfFCQ6hsq>E(=7&NQ#Z@=sJh@~Doy%~EaZWgp0wA&Mj74G-ST`^ zcO3W|*{W_>UV_r}E7HnLwrW?9NxZbL=Qg`#KM42(W_Xf$9C;sf;4}Ih&I8G+EMW!X pg(QmN%RoSs|A+{pcvsjG-g+h+`9(PPOc?o9IJq0@6}!D{{sJcrn|}ZR literal 0 HcmV?d00001 diff --git a/tests/api/__pycache__/test_scrape_endpoints.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_scrape_endpoints.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7fe7022935541395238c89c92eed70d0e05e826d GIT binary patch literal 5977 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 literal 0 HcmV?d00001 diff --git a/tests/api/__pycache__/test_version.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_version.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d42ca7925cfe033838feaf3f7420b0871619a24 GIT binary patch literal 1125 zcmZt_O=}ZDbY^#(CcCY*wrFdGT?&Qn!K79Tf*;s}pea;I6oedxX);Y$HoME4jWrde z2L-{iKf<5ltrbdTu6pTB$-%2LyE|=)4$1rees719lQxoX`lK6tmH3BZ^fL-#cuNwW zQ4;}b;-;~I!N{;#x47Y<`WTcvr7<_g>t!fQ+PaFM(lz|Cy;=Ji<0<#npFZ|pG2lTQ zRqYcMc+Ryd<&LH)c zmXpD%&+Rt_;oZwbD?YE%Bp}7k?6;x}j3;r#nQsU)8LTL4xzZ4M{|MOjHP`+f0eYh4rjNc|{Ka zVh}TAJBA)j345J{G@_~0_$H|95spfkw{#t9JQa!YLI#fVxspK8X1g?P@5tpQYQfbW4Wev?SzBKa%t}kRq|D2AdvT{9viopN gvg$M7^Ah|rF~+|Q16zN}2;=wY2(1iDCiXJdzYV$p-~a#s literal 0 HcmV?d00001 diff --git a/tests/api/__pycache__/test_webhooks.cpython-313-pytest-9.0.2.pyc b/tests/api/__pycache__/test_webhooks.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e4e3f5543b7790923ad90116945c68bd1652dc30 GIT binary patch literal 8289 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 literal 0 HcmV?d00001 diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..e2a4f8b --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,56 @@ +""" +Tests auth API. +""" + +from dataclasses import dataclass +import pytest +from fastapi import HTTPException + +from pricewatch.app.api.main import require_token + + +@dataclass +class FakeRedisConfig: + url: str + + +@dataclass +class FakeDbConfig: + url: str + + +@dataclass +class FakeAppConfig: + db: FakeDbConfig + redis: FakeRedisConfig + api_token: str + + +def test_missing_token_returns_401(monkeypatch): + """Sans token, retourne 401.""" + config = FakeAppConfig( + db=FakeDbConfig(url="sqlite:///:memory:"), + redis=FakeRedisConfig(url="redis://localhost:6379/0"), + api_token="secret", + ) + monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: config) + + with pytest.raises(HTTPException) as excinfo: + require_token(None) + + assert excinfo.value.status_code == 401 + + +def test_bad_token_returns_403(monkeypatch): + """Token invalide retourne 403.""" + config = FakeAppConfig( + db=FakeDbConfig(url="sqlite:///:memory:"), + redis=FakeRedisConfig(url="redis://localhost:6379/0"), + api_token="secret", + ) + monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: config) + + with pytest.raises(HTTPException) as excinfo: + require_token("Bearer nope") + + assert excinfo.value.status_code == 403 diff --git a/tests/api/test_backend_logs.py b/tests/api/test_backend_logs.py new file mode 100644 index 0000000..7d07d05 --- /dev/null +++ b/tests/api/test_backend_logs.py @@ -0,0 +1,30 @@ +""" +Tests API logs backend. +""" + +from pricewatch.app.api.main import BACKEND_LOGS, list_backend_logs, preview_scrape +from pricewatch.app.api.schemas import ScrapePreviewRequest +from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot + + +def test_backend_logs_capture_preview(monkeypatch): + BACKEND_LOGS.clear() + + snapshot = ProductSnapshot( + source="amazon", + url="https://example.com", + title="Produit", + price=9.99, + currency="EUR", + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + def fake_scrape(url, use_playwright=None, save_db=False): + return {"success": True, "snapshot": snapshot, "error": None} + + monkeypatch.setattr("pricewatch.app.api.main.scrape_product", fake_scrape) + + preview_scrape(ScrapePreviewRequest(url="https://example.com")) + logs = list_backend_logs() + assert logs + assert logs[-1].message.startswith("Preview scraping") diff --git a/tests/api/test_filters_exports.py b/tests/api/test_filters_exports.py new file mode 100644 index 0000000..303cb49 --- /dev/null +++ b/tests/api/test_filters_exports.py @@ -0,0 +1,239 @@ +""" +Tests filtres avances et exports API. +""" + +from datetime import datetime, timedelta +import json + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from pricewatch.app.api.main import ( + export_logs, + export_prices, + export_products, + list_logs, + list_prices, + list_products, +) +from pricewatch.app.db.models import Base, PriceHistory, Product, ScrapingLog + + +def _make_session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + return engine, session + + +def test_list_products_filters_latest_price_and_stock(): + engine, session = _make_session() + try: + product_a = Product( + source="amazon", + reference="REF-A", + url="https://example.com/a", + title="A", + category="Test", + currency="EUR", + first_seen_at=datetime(2026, 1, 14, 10, 0, 0), + last_updated_at=datetime(2026, 1, 15, 9, 0, 0), + ) + product_b = Product( + source="amazon", + reference="REF-B", + url="https://example.com/b", + title="B", + category="Test", + currency="EUR", + first_seen_at=datetime(2026, 1, 14, 10, 0, 0), + last_updated_at=datetime(2026, 1, 15, 9, 5, 0), + ) + session.add_all([product_a, product_b]) + session.commit() + + history = [ + PriceHistory( + product_id=product_a.id, + price=80, + shipping_cost=0, + stock_status="out_of_stock", + fetch_method="http", + fetch_status="success", + fetched_at=datetime(2026, 1, 15, 8, 0, 0), + ), + PriceHistory( + product_id=product_a.id, + price=100, + shipping_cost=0, + stock_status="in_stock", + fetch_method="http", + fetch_status="success", + fetched_at=datetime(2026, 1, 15, 9, 0, 0), + ), + PriceHistory( + product_id=product_b.id, + price=200, + shipping_cost=10, + stock_status="in_stock", + fetch_method="http", + fetch_status="success", + fetched_at=datetime(2026, 1, 15, 9, 5, 0), + ), + ] + session.add_all(history) + session.commit() + + filtered = list_products(price_min=150, session=session) + assert len(filtered) == 1 + assert filtered[0].reference == "REF-B" + + filtered_stock = list_products(stock_status="in_stock", session=session) + assert {item.reference for item in filtered_stock} == {"REF-A", "REF-B"} + finally: + session.close() + engine.dispose() + + +def test_list_prices_filters(): + engine, session = _make_session() + try: + product = Product( + source="amazon", + reference="REF-1", + url="https://example.com/1", + title="Produit", + category="Test", + currency="EUR", + first_seen_at=datetime(2026, 1, 14, 10, 0, 0), + last_updated_at=datetime(2026, 1, 14, 11, 0, 0), + ) + session.add(product) + session.commit() + + history = [ + PriceHistory( + product_id=product.id, + price=50, + shipping_cost=0, + stock_status="in_stock", + fetch_method="http", + fetch_status="success", + fetched_at=datetime(2026, 1, 14, 12, 0, 0), + ), + PriceHistory( + product_id=product.id, + price=120, + shipping_cost=0, + stock_status="in_stock", + fetch_method="http", + fetch_status="failed", + fetched_at=datetime(2026, 1, 15, 12, 0, 0), + ), + ] + session.add_all(history) + session.commit() + + results = list_prices( + product_id=product.id, + price_min=100, + fetch_status="failed", + session=session, + ) + assert len(results) == 1 + assert results[0].price == 120 + finally: + session.close() + engine.dispose() + + +def test_list_logs_filters(): + engine, session = _make_session() + try: + now = datetime(2026, 1, 15, 10, 0, 0) + logs = [ + ScrapingLog( + product_id=None, + url="https://example.com/a", + source="amazon", + reference="REF-A", + fetch_method="http", + fetch_status="success", + fetched_at=now, + ), + ScrapingLog( + product_id=None, + url="https://example.com/b", + source="amazon", + reference="REF-B", + fetch_method="http", + fetch_status="failed", + fetched_at=now - timedelta(hours=2), + ), + ] + session.add_all(logs) + session.commit() + + filtered = list_logs( + fetch_status="success", + fetched_after=now - timedelta(hours=1), + session=session, + ) + assert len(filtered) == 1 + assert filtered[0].reference == "REF-A" + finally: + session.close() + engine.dispose() + + +def test_exports_csv_and_json(): + engine, session = _make_session() + try: + product = Product( + source="amazon", + reference="REF-EXPORT", + url="https://example.com/export", + title="Export", + category="Test", + currency="EUR", + first_seen_at=datetime(2026, 1, 14, 10, 0, 0), + last_updated_at=datetime(2026, 1, 14, 11, 0, 0), + ) + session.add(product) + session.commit() + + session.add( + PriceHistory( + product_id=product.id, + price=99, + shipping_cost=0, + stock_status="in_stock", + fetch_method="http", + fetch_status="success", + fetched_at=datetime(2026, 1, 14, 12, 0, 0), + ) + ) + session.add( + ScrapingLog( + product_id=product.id, + url=product.url, + source=product.source, + reference=product.reference, + fetch_method="http", + fetch_status="success", + fetched_at=datetime(2026, 1, 14, 12, 0, 0), + ) + ) + session.commit() + + csv_response = export_products(format="csv", session=session) + assert csv_response.media_type == "text/csv" + assert "products.csv" in csv_response.headers.get("Content-Disposition", "") + assert "REF-EXPORT" in csv_response.body.decode("utf-8") + + json_response = export_logs(format="json", session=session) + payload = json.loads(json_response.body.decode("utf-8")) + assert payload[0]["reference"] == "REF-EXPORT" + finally: + session.close() + engine.dispose() diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..56cd1c9 --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,40 @@ +""" +Tests endpoint /health. +""" + +from dataclasses import dataclass + +from pricewatch.app.api.main import health_check + + +@dataclass +class FakeRedisConfig: + url: str + + +@dataclass +class FakeDbConfig: + url: str + + +@dataclass +class FakeAppConfig: + db: FakeDbConfig + redis: FakeRedisConfig + api_token: str + + +def test_health_ok(monkeypatch): + """Health retourne db/redis true.""" + config = FakeAppConfig( + db=FakeDbConfig(url="sqlite:///:memory:"), + redis=FakeRedisConfig(url="redis://localhost:6379/0"), + api_token="secret", + ) + monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: config) + monkeypatch.setattr("pricewatch.app.api.main.check_db_connection", lambda cfg: True) + monkeypatch.setattr("pricewatch.app.api.main.check_redis_connection", lambda url: True) + + result = health_check() + assert result.db is True + assert result.redis is True diff --git a/tests/api/test_http_integration.py b/tests/api/test_http_integration.py new file mode 100644 index 0000000..ee51c74 --- /dev/null +++ b/tests/api/test_http_integration.py @@ -0,0 +1,47 @@ +""" +Tests HTTP d'integration contre l'API Docker. +""" + +import os + +import pytest +import httpx + + +API_BASE = os.getenv("PW_API_BASE", "http://localhost:8001") +API_TOKEN = os.getenv("PW_API_TOKEN", "change_me") + + +def _client() -> httpx.Client: + return httpx.Client(base_url=API_BASE, timeout=2.0) + + +def _is_api_up() -> bool: + try: + with _client() as client: + resp = client.get("/health") + return resp.status_code == 200 + except Exception: + return False + + +@pytest.mark.skipif(not _is_api_up(), reason="API Docker indisponible") +def test_health_endpoint(): + """/health repond avec db/redis.""" + with _client() as client: + resp = client.get("/health") + assert resp.status_code == 200 + payload = resp.json() + assert "db" in payload and "redis" in payload + + +@pytest.mark.skipif(not _is_api_up(), reason="API Docker indisponible") +def test_products_requires_token(): + """/products demande un token valide.""" + with _client() as client: + resp = client.get("/products") + assert resp.status_code == 401 + + resp = client.get("/products", headers={"Authorization": f"Bearer {API_TOKEN}"}) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) diff --git a/tests/api/test_products.py b/tests/api/test_products.py new file mode 100644 index 0000000..55e4e74 --- /dev/null +++ b/tests/api/test_products.py @@ -0,0 +1,37 @@ +""" +Tests API produits en lecture seule. +""" + +from datetime import datetime + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from pricewatch.app.api.main import list_products +from pricewatch.app.db.models import Base, Product + + +def test_list_products(): + """Liste des produits.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + + product = Product( + source="amazon", + reference="REF1", + url="https://example.com", + title="Produit", + category="Test", + currency="EUR", + first_seen_at=datetime(2026, 1, 14, 16, 0, 0), + last_updated_at=datetime(2026, 1, 14, 16, 0, 0), + ) + session.add(product) + session.commit() + + data = list_products(session=session, limit=50, offset=0) + assert len(data) == 1 + assert data[0].reference == "REF1" + session.close() + engine.dispose() diff --git a/tests/api/test_scrape_endpoints.py b/tests/api/test_scrape_endpoints.py new file mode 100644 index 0000000..25e1ebb --- /dev/null +++ b/tests/api/test_scrape_endpoints.py @@ -0,0 +1,55 @@ +""" +Tests API preview/commit scraping. +""" + +from datetime import datetime + +from pricewatch.app.api.main import commit_scrape, preview_scrape +from pricewatch.app.api.schemas import ScrapeCommitRequest, ScrapePreviewRequest +from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot + + +def test_preview_scrape_returns_snapshot(monkeypatch): + snapshot = ProductSnapshot( + source="amazon", + url="https://example.com", + title="Produit", + price=9.99, + currency="EUR", + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + def fake_scrape(url, use_playwright=None, save_db=False): + return {"success": True, "snapshot": snapshot, "error": None} + + monkeypatch.setattr("pricewatch.app.api.main.scrape_product", fake_scrape) + + response = preview_scrape(ScrapePreviewRequest(url="https://example.com")) + assert response.success is True + assert response.snapshot["source"] == "amazon" + assert response.snapshot["price"] == 9.99 + + +def test_commit_scrape_persists_snapshot(monkeypatch): + snapshot = ProductSnapshot( + source="amazon", + url="https://example.com", + title="Produit", + price=19.99, + currency="EUR", + fetched_at=datetime(2026, 1, 15, 10, 0, 0), + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + class FakePipeline: + def __init__(self, config=None): + self.config = config + + def process_snapshot(self, snapshot, save_to_db=True): + return 42 + + monkeypatch.setattr("pricewatch.app.api.main.ScrapingPipeline", FakePipeline) + + response = commit_scrape(ScrapeCommitRequest(snapshot=snapshot.model_dump(mode="json"))) + assert response.success is True + assert response.product_id == 42 diff --git a/tests/api/test_uvicorn_logs.py b/tests/api/test_uvicorn_logs.py new file mode 100644 index 0000000..7bb823f --- /dev/null +++ b/tests/api/test_uvicorn_logs.py @@ -0,0 +1,16 @@ +""" +Tests API logs Uvicorn. +""" + +from pricewatch.app.api.main import list_uvicorn_logs + + +def test_list_uvicorn_logs_reads_file(monkeypatch, tmp_path): + log_file = tmp_path / "uvicorn.log" + log_file.write_text("ligne-1\nligne-2\n", encoding="utf-8") + + monkeypatch.setattr("pricewatch.app.api.main.UVICORN_LOG_PATH", log_file) + + response = list_uvicorn_logs(limit=1) + assert len(response) == 1 + assert response[0].line == "ligne-2" diff --git a/tests/api/test_version.py b/tests/api/test_version.py new file mode 100644 index 0000000..e6af5cd --- /dev/null +++ b/tests/api/test_version.py @@ -0,0 +1,11 @@ +""" +Tests API version. +""" + +from pricewatch.app.api.main import version_info + + +def test_version_info(): + """Retourne la version API.""" + response = version_info() + assert response.api_version diff --git a/tests/api/test_webhooks.py b/tests/api/test_webhooks.py new file mode 100644 index 0000000..de2e193 --- /dev/null +++ b/tests/api/test_webhooks.py @@ -0,0 +1,72 @@ +""" +Tests API webhooks. +""" + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from pricewatch.app.api.main import ( + create_webhook, + delete_webhook, + list_webhooks, + send_webhook_test, + update_webhook, +) +from pricewatch.app.api.schemas import WebhookCreate, WebhookUpdate +from pricewatch.app.db.models import Base + + +def _make_session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + return engine, session + + +def test_webhook_crud_and_test(monkeypatch): + engine, session = _make_session() + try: + payload = WebhookCreate(event="price_changed", url="https://example.com/webhook") + created = create_webhook(payload, session=session) + assert created.id > 0 + + items = list_webhooks(session=session) + assert len(items) == 1 + + updated = update_webhook(created.id, WebhookUpdate(enabled=False), session=session) + assert updated.enabled is False + + with pytest.raises(HTTPException) as excinfo: + send_webhook_test(created.id, session=session) + assert excinfo.value.status_code == 409 + + update_webhook(created.id, WebhookUpdate(enabled=True), session=session) + + called = {} + + def fake_post(url, json, headers, timeout): + called["url"] = url + called["json"] = json + called["headers"] = headers + called["timeout"] = timeout + + class FakeResponse: + status_code = 200 + + def raise_for_status(self): + return None + + return FakeResponse() + + monkeypatch.setattr("pricewatch.app.api.main.httpx.post", fake_post) + response = send_webhook_test(created.id, session=session) + assert response.status == "sent" + assert called["json"]["event"] == "test" + + delete_webhook(created.id, session=session) + assert list_webhooks(session=session) == [] + finally: + session.close() + engine.dispose() diff --git a/tests/cli/__pycache__/test_cli_worker_end_to_end.cpython-313-pytest-9.0.2.pyc b/tests/cli/__pycache__/test_cli_worker_end_to_end.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..660fcd4470797006120de3c0665924dc1e21f5f8 GIT binary patch literal 10390 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 literal 0 HcmV?d00001 diff --git a/tests/cli/__pycache__/test_enqueue_schedule_cli.cpython-313-pytest-9.0.2.pyc b/tests/cli/__pycache__/test_enqueue_schedule_cli.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14d439eb97acf50da1eda349e176b8425f1b5f4b GIT binary patch literal 9856 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 literal 0 HcmV?d00001 diff --git a/tests/cli/__pycache__/test_worker_cli.cpython-313-pytest-9.0.2.pyc b/tests/cli/__pycache__/test_worker_cli.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..791c65f0f91335ee820b233ea32af77e52f9eb73 GIT binary patch literal 5977 zcmdT|U2Gf25#A$@NtwOHB2g>}6SvY#~ELPYx12 zH5&8BU^yXEK$Z?=eR^h)uzpsG>OpYHBDtR%ScL*%DzNE%iYtcUfLjcM zXAGm}R+~1Kdko`Z)3nF|T1;4s~2;me07Z&Co1=#xT5w*{~{x*=R89OcUm3 z7{Wa3!-3EjEEO=UdD!tS@+c-HHvX*oguugBMXfbaKvyBHjsWY5LP}Ts7@U`wtfzJP zbONH1z^H&EsV7+qwoHbr!WrKeyW`;ourpl=%%W)ww1~(zps++{`)rg7n$_$uc12Pq zcCabPJh>%J=i@wiu}PcMkID&hQ~ z6OJU-br1?mF#8fbyQzd4T~wAv+pE@Koop*K=VNR;Jb2Qq*Qry5s#w-@AoFm{U_ml2 zo%cl-gOV+%w-FdDMCrMfyP8{3wzrh+*ME3VDTtMTqtHFRqj0ET*ff;k0MFzV){kP0 zA48uow8y@R>=|S`kZnSS8#}V|aBzkUyLwo7i~KbfPdxiLBPEJAMsWKTA0hKQp~jjc zi8HyHSZ`)_WF$5U{zTH%ISi(t5{9sghDhM&Z$nEJ zHe_TJ3|AKok#a1EK`=KH>_&PGc6EVjwAafL%&D`I^|j-{7*Exd15LfB5FduaJs* zv1x%P{Y*B!=Ca2d32((!1=t~JiOj@y_;;wd4&izYQxNZ|B2H=JhIT?)Y$v2K+pL|b znumBEQAPw?7+$kdp`KS5vt3AS-XSRXsv-&CUG=v`^7~VMUQ*67w`Me%z2+jtOd%$T zXMDYDv_0j#B;dj=iO0c&4~cvffhn4O6W9>^J-kL2$w!%wdi+t7m}@9o$PN!X?r@kD z8IF5P`Ybj+eoI=|{q>#oD|_-@<_GP9Hm+f5SKceo%aDvKZk1-<)W&`BXx>8{=QZud ztvHW^9l(f-!K8J~&u22+PAH{iN5=@Lm)ti>Eva z*s8&}0{G5AkjfJb5hhPs&KvFp%Hxodc{-dq&rH;>h}fBo_ltoHM(Yeh4JLfj-s!%oNZTF063slI{hG(9oXn|*sh}8nbnPkjme84#CvYOd2 z=;gX?IsgG(N5F)9nyWR}xj?Vf%|_)M_)oxWh8T{kI04^pyQ7tnXl1Wo@$K)6Rt^Y9 zc?b&tMhTkm_wMG;*|F>u(8b;F9fnf^oAgh;n|?C4c>Lpm?bq46hZj#hkaH{Y=9awq zedWf^KfEw|SAO+=NAyAVz3k0QYvj~j`K1TF1J_==`r7*kJ{Ws%?B>qpkz>pG$veHr z7mq(s`&ZPhEp_X%tX;n-SWDGbV7nCu?u-3uUcN4H`o2J7_Od3JV~< zuWnvZhg#~;vOIjFd1D?Q^DT9FMQ;CY%frj+P+0W~_vGQX$3j`FQ-g+3`wp54mBPYZ zc~~5@HHSJxM}G(GvfiO~BwZRLfF#IzU~{gG0SGCDs0d=!CLQp{8a#t>@1lr>Oji?B z^>|SV(M-G?Uk~#*vM#$b<%B>pXM+qR&`gMZN+BBPi~(pSQBDGyNzP72&`fGBL_Vbu z*F<~$6PoFavM%~5he#usn?N(2Uh89^uoq;<)ZEjwLyYoN%|wu^b)}7FQtuGcZyjKK$ZeRmfd+zvm60u0QdL^ z2?}k5v=x#dZe)Ae0T?ac=c5)pmmP<)Ls&hGYy#O4WZyw{6xlIk zlgN%E`!2E(Bwwrl*0YL(K50EG3RhkCG(O7eEFd9{?1%D^GUegPVhQdPn|G*r2#9PyBlP zSL5*baJ;2XtjO)(ZFyo@E&A2Fk#eX2!VdwKwAEXk+C4NCHiQMx7n?Ibz*7e7^$L8> zQUZi2;>^Ed_?Wcd<32cE1x7D1t3i1>6!BDBCUyx=G_KUC$M8DfNxwVqMHwq#*x;k4 z*l>J9ommxn34vw-pl|_jGQ+nKe-q+eAds{`fg-A1--oy|=701&#Lj^-o+}S8ghz2n zk{-umlKey=lJsM8k39DY>HQ=5+Mh}BNirsFd`y5n$)u#>V*>2cjjB{w-PR)=k%T|~ E0drSyumAu6 literal 0 HcmV?d00001 diff --git a/tests/cli/test_cli_worker_end_to_end.py b/tests/cli/test_cli_worker_end_to_end.py new file mode 100644 index 0000000..eb5de48 --- /dev/null +++ b/tests/cli/test_cli_worker_end_to_end.py @@ -0,0 +1,130 @@ +""" +Test end-to-end: CLI enqueue -> worker -> DB via Redis. +""" + +from dataclasses import dataclass +from datetime import datetime + +import pytest +import redis +from rq import Queue +from rq.worker import SimpleWorker +from typer.testing import CliRunner + +from pricewatch.app.cli import main as cli_main +from pricewatch.app.core.registry import get_registry +from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot +from pricewatch.app.db.connection import get_session, init_db, reset_engine +from pricewatch.app.db.models import Product, ScrapingLog +from pricewatch.app.stores.base import BaseStore +from pricewatch.app.tasks import scrape as scrape_task + + +@dataclass +class FakeDbConfig: + url: str + + +@dataclass +class FakeRedisConfig: + url: str + + +@dataclass +class FakeAppConfig: + db: FakeDbConfig + redis: FakeRedisConfig + debug: bool = False + enable_db: bool = True + default_use_playwright: bool = False + default_playwright_timeout: int = 1000 + + +class DummyStore(BaseStore): + def __init__(self) -> None: + super().__init__(store_id="dummy") + + def match(self, url: str) -> float: + return 1.0 if "example.com" in url else 0.0 + + def canonicalize(self, url: str) -> str: + return url + + def extract_reference(self, url: str) -> str | None: + return "REF-CLI" + + def parse(self, html: str, url: str) -> ProductSnapshot: + return ProductSnapshot( + source=self.store_id, + url=url, + fetched_at=datetime(2026, 1, 14, 15, 0, 0), + title="Produit cli", + price=49.99, + currency="EUR", + reference="REF-CLI", + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + +class DummyFetchResult: + def __init__(self, html: str) -> None: + self.success = True + self.html = html + self.error = None + self.duration_ms = 20 + + +def _redis_available(redis_url: str) -> bool: + try: + conn = redis.from_url(redis_url) + conn.ping() + return True + except Exception: + return False + + +@pytest.mark.skipif(not _redis_available("redis://localhost:6379/0"), reason="Redis indisponible") +def test_cli_enqueue_worker_persists_db(tmp_path, monkeypatch): + """Enqueue via CLI, execution worker, persistence DB.""" + reset_engine() + db_path = tmp_path / "cli-worker.db" + redis_url = "redis://localhost:6379/0" + config = FakeAppConfig( + db=FakeDbConfig(url=f"sqlite:///{db_path}"), + redis=FakeRedisConfig(url=redis_url), + ) + init_db(config) + + registry = get_registry() + previous_stores = list(registry._stores) + registry._stores = [] + registry.register(DummyStore()) + + monkeypatch.setattr(cli_main, "get_config", lambda: config) + monkeypatch.setattr(scrape_task, "get_config", lambda: config) + monkeypatch.setattr(scrape_task, "setup_stores", lambda: None) + monkeypatch.setattr(scrape_task, "fetch_http", lambda url: DummyFetchResult("")) + + queue_name = "test-cli" + redis_conn = redis.from_url(redis_url) + queue = Queue(queue_name, connection=redis_conn) + queue.empty() + + runner = CliRunner() + try: + result = runner.invoke( + cli_main.app, + ["enqueue", "https://example.com/product", "--queue", queue_name, "--save-db"], + ) + assert result.exit_code == 0 + + worker = SimpleWorker([queue], connection=redis_conn) + worker.work(burst=True) + finally: + queue.empty() + registry._stores = previous_stores + reset_engine() + + with get_session(config) as session: + assert session.query(Product).count() == 1 + assert session.query(ScrapingLog).count() == 1 diff --git a/tests/cli/test_enqueue_schedule_cli.py b/tests/cli/test_enqueue_schedule_cli.py new file mode 100644 index 0000000..9c5e19d --- /dev/null +++ b/tests/cli/test_enqueue_schedule_cli.py @@ -0,0 +1,83 @@ +""" +Tests CLI pour enqueue/schedule avec gestion Redis. +""" + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from pricewatch.app.cli import main as cli_main + + +class DummyScheduler: + def __init__(self, *args, **kwargs) -> None: + self.enqueue_calls = [] + self.schedule_calls = [] + + def enqueue_immediate(self, url, use_playwright=None, save_db=True): + self.enqueue_calls.append((url, use_playwright, save_db)) + return SimpleNamespace(id="job-123") + + def schedule_product(self, url, interval_hours=24, use_playwright=None, save_db=True): + self.schedule_calls.append((url, interval_hours, use_playwright, save_db)) + return SimpleNamespace(job_id="job-456", next_run=SimpleNamespace(isoformat=lambda: "2026")) + + +def test_enqueue_cli_success(monkeypatch): + """La commande enqueue retourne un job id.""" + runner = CliRunner() + dummy = DummyScheduler() + + monkeypatch.setattr(cli_main, "ScrapingScheduler", lambda *args, **kwargs: dummy) + + result = runner.invoke(cli_main.app, ["enqueue", "https://example.com/product"]) + + assert result.exit_code == 0 + assert "job-123" in result.output + + +def test_schedule_cli_success(monkeypatch): + """La commande schedule retourne un job id et une date.""" + runner = CliRunner() + dummy = DummyScheduler() + + monkeypatch.setattr(cli_main, "ScrapingScheduler", lambda *args, **kwargs: dummy) + + result = runner.invoke( + cli_main.app, + ["schedule", "https://example.com/product", "--interval", "12"], + ) + + assert result.exit_code == 0 + assert "job-456" in result.output + assert "2026" in result.output + + +def test_enqueue_cli_redis_unavailable(monkeypatch): + """La commande enqueue echoue si Redis est indisponible.""" + runner = CliRunner() + + def raise_redis(*args, **kwargs): + raise cli_main.RedisUnavailableError("Redis non disponible") + + monkeypatch.setattr(cli_main, "ScrapingScheduler", raise_redis) + + result = runner.invoke(cli_main.app, ["enqueue", "https://example.com/product"]) + + assert result.exit_code == 1 + assert "Redis non disponible" in result.output + + +def test_schedule_cli_redis_unavailable(monkeypatch): + """La commande schedule echoue si Redis est indisponible.""" + runner = CliRunner() + + def raise_redis(*args, **kwargs): + raise cli_main.RedisUnavailableError("Redis non disponible") + + monkeypatch.setattr(cli_main, "ScrapingScheduler", raise_redis) + + result = runner.invoke(cli_main.app, ["schedule", "https://example.com/product"]) + + assert result.exit_code == 1 + assert "Redis non disponible" in result.output diff --git a/tests/cli/test_run_db.py b/tests/cli/test_run_db.py old mode 100755 new mode 100644 diff --git a/tests/cli/test_run_no_db.py b/tests/cli/test_run_no_db.py new file mode 100644 index 0000000..246d9cf --- /dev/null +++ b/tests/cli/test_run_no_db.py @@ -0,0 +1,106 @@ +""" +Tests pour la compatibilite --no-db. +""" + +from dataclasses import dataclass +from pathlib import Path + +from typer.testing import CliRunner + +from pricewatch.app.cli import main as cli_main +from pricewatch.app.core.registry import get_registry +from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot +from pricewatch.app.db.connection import get_session, init_db, reset_engine +from pricewatch.app.db.models import Product +from pricewatch.app.stores.base import BaseStore + + +@dataclass +class FakeDbConfig: + url: str + + +@dataclass +class FakeAppConfig: + db: FakeDbConfig + debug: bool = False + enable_db: bool = True + + +class DummyStore(BaseStore): + def __init__(self) -> None: + super().__init__(store_id="dummy") + + def match(self, url: str) -> float: + return 1.0 if "example.com" in url else 0.0 + + def canonicalize(self, url: str) -> str: + return url + + def extract_reference(self, url: str) -> str | None: + return "REF-NODB" + + def parse(self, html: str, url: str) -> ProductSnapshot: + return ProductSnapshot( + source=self.store_id, + url=url, + title="Produit nodb", + price=9.99, + currency="EUR", + reference="REF-NODB", + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + +class DummyFetchResult: + def __init__(self, html: str) -> None: + self.success = True + self.html = html + self.error = None + + +def test_cli_run_no_db(tmp_path, monkeypatch): + """Le flag --no-db evite toute ecriture DB.""" + reset_engine() + db_path = tmp_path / "nodb.db" + config = FakeAppConfig(db=FakeDbConfig(url=f"sqlite:///{db_path}")) + init_db(config) + + yaml_path = tmp_path / "config.yaml" + out_path = tmp_path / "out.json" + yaml_path.write_text( + """ +urls: + - "https://example.com/product" +options: + use_playwright: false + save_html: false + save_screenshot: false +""", + encoding="utf-8", + ) + + registry = get_registry() + previous_stores = list(registry._stores) + registry._stores = [] + registry.register(DummyStore()) + + monkeypatch.setattr(cli_main, "get_config", lambda: config) + monkeypatch.setattr(cli_main, "setup_stores", lambda: None) + monkeypatch.setattr(cli_main, "fetch_http", lambda url: DummyFetchResult("")) + + runner = CliRunner() + try: + result = runner.invoke( + cli_main.app, + ["run", "--yaml", str(yaml_path), "--out", str(out_path), "--no-db"], + ) + finally: + registry._stores = previous_stores + reset_engine() + + assert result.exit_code == 0 + assert out_path.exists() + + with get_session(config) as session: + assert session.query(Product).count() == 0 diff --git a/tests/cli/test_worker_cli.py b/tests/cli/test_worker_cli.py new file mode 100644 index 0000000..040b7a3 --- /dev/null +++ b/tests/cli/test_worker_cli.py @@ -0,0 +1,54 @@ +""" +Tests pour les commandes worker RQ via CLI. +""" + +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner + +from pricewatch.app.cli import main as cli_main + + +class DummyRedis: + def ping(self) -> bool: + return True + + +class DummyWorker: + def __init__(self, queues, connection=None) -> None: + self.queues = queues + self.connection = connection + self.work_calls = [] + + def work(self, with_scheduler: bool = True): + self.work_calls.append(with_scheduler) + + +def test_worker_cli_success(monkeypatch): + """Le worker demarre quand Redis est disponible.""" + runner = CliRunner() + dummy_worker = DummyWorker([]) + + monkeypatch.setattr(cli_main, "Worker", lambda queues, connection=None: dummy_worker) + monkeypatch.setattr(cli_main.redis, "from_url", lambda url: DummyRedis()) + + result = runner.invoke(cli_main.app, ["worker", "--no-scheduler"]) + + assert result.exit_code == 0 + assert dummy_worker.work_calls == [False] + + +def test_worker_cli_redis_down(monkeypatch): + """Le worker echoue proprement si Redis est indisponible.""" + runner = CliRunner() + + def raise_connection(url): + raise cli_main.redis.exceptions.ConnectionError("redis down") + + monkeypatch.setattr(cli_main.redis, "from_url", raise_connection) + + result = runner.invoke(cli_main.app, ["worker"]) + + assert result.exit_code == 1 + assert "Impossible de se connecter a Redis" in result.output diff --git a/tests/core/__pycache__/test_io.cpython-313-pytest-9.0.2.pyc b/tests/core/__pycache__/test_io.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 diff --git a/tests/core/__pycache__/test_registry_integration.cpython-313-pytest-9.0.2.pyc b/tests/core/__pycache__/test_registry_integration.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 diff --git a/tests/core/test_io.py b/tests/core/test_io.py old mode 100755 new mode 100644 diff --git a/tests/core/test_registry_integration.py b/tests/core/test_registry_integration.py old mode 100755 new mode 100644 diff --git a/tests/db/__pycache__/test_bulk_persistence.cpython-313-pytest-9.0.2.pyc b/tests/db/__pycache__/test_bulk_persistence.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e3b502baf9423a4be3d3b638138e2cc703d4113 GIT binary patch literal 3546 zcmbtWO>7&-6`m!Rnyd87JAtRhmsRisvhTgSFS#c9eFn*ux#D{@3JS?+FT zmbIlog$op@4HPhXNFks(=+Hybla57CEznDEQe{`dKtThvMd6-g=+GjkzFBg(bZj6g zy5!D$?|bjf@6LO37LP{|Jb(CAtv(z;=xFH@h2 z@&OW5xyHW|%7;kE#qx@h50kKq11phyltf(|T#4o5B%V)@L_SH9`E%r)%ZFBa^1Y-t zpCTz&R#y7*=gE1%;UPr&YrafmYbmmdsYBH&Rx2AisbST?HB7K-TTP-Gx@u!W>(s$! z1*>CIlanenb(?Nj4$WjEPx)-#%1BJ9>JE154GcJ5AsD1GHfwd06ETV@ty^Y8e-D$4 z%)+nZwPx)NbKPQ57ix~~G%1VT!cJx54t6%IDoegitZK93XzlJYJ|=%xr3TU{e z4GY+|Xx*{MW`z$(JV^k4c+Vzb7d}KKNhmaFu8mDp5<7wlve0{VA>?6!ib!~?s1){C z&9@+pqPaF}PfIQ+rd;$DE_%E` zMOn;2MR%$QMUje%QsI)&d$m{@SFd`!Kvr=Sl}3dmTD8^-mj&n5*4?g^Ry4o0Qw%RI zBV_$%tZj5#s=kR(>8kK42!$Y^9!1)@Vq{j*dVctD);HJQf33IlbzxL^RUohSdj2Mm z)eFz{lJ)Ea8in-`vk`;PVIR82HYseqy4&k}5{${4$;+(+q7NC6MZm@e;^j_gHmm{01u^UhU~?1F!J2 z_kXpqj+XqF(7KdKZ%w_4RniHqolKxUmjzU+n^eVw;3mm#_0so^x`P*Ux!hs{Hy|P} zLY$V@>SpyR1P8=|dPCo`%&p-K$FV8c;d^?+HgL9LHF9<<yBCj37*T>ng3?7uq796-kN~y5Nxy(N07SNB%`n(g&bDF%-&kHf~#fS zVF9P^7?=fYQU|qCX~O8u$|egFybie!QXLCad5+3RpZk`UGSb$MuZ-E73mF>upbgX| zm7{q<0ccZmf&$Q{rUeC{O-*KK_WdR%o007?-q1{jW+L0L3Qf}i3b4A=>S79JXOVW+)(wL|x2~T!PW_8F;JJ+nsvje zo0J6&3oIy0m*L{kG^(y?xn@{vx=}Xu24>+h)%n)hgeAV?P8;qUHwm!_OO@9x($JkU zzGoY{sXL&7Q5Y7#OH}I=t`0a8XTjEWh4941Wd7MRf4zE-DV^Cw`IuU3I>ow#*>2$* z-NI|Fd9;0|o|7h9Qfq8eGtWtLlwT3l?uRYqHmnBD)#`?UDL*Oi;)+9av_f<{$5)c( zs%tKQbu->8cir2v_9h#47u(gidjw~=vrm*PLwEe zoD5t&9=LSeKfO1zcV)ltwUY~9+kO4xS3WN6_3nNDx6C>?g+$lM?{; zQf_gOoIL`y`#__i(I4~9x81E;qv@~uv<6XGj zCGdNxj&nDFhCWl0kKzyGKTjSj2|mv!O8P&iem?0hu%AnxuRmx?ih(kqq{$hjW)-lYszELkFy&j&Ow+*MCPff=Vxi+uocbk6dgk*<^1l@%Nk2tT g(8YhC%b%ia|3sxjRQfBL{uC{q1qY=;vI_S91=;!@wg3PC literal 0 HcmV?d00001 diff --git a/tests/db/__pycache__/test_connection.cpython-313-pytest-9.0.2.pyc b/tests/db/__pycache__/test_connection.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 diff --git a/tests/db/__pycache__/test_models.cpython-313-pytest-9.0.2.pyc b/tests/db/__pycache__/test_models.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 index 4dfdd8da99e318e6ef62bba1ab6e19d46bb88555..6d8f8e8a08c32be46e1193a144954a6ed4e50708 GIT binary patch delta 1945 zcmd5+O>7%Q6yDi?uf4mr9DB3=cM_DirL+l2XlNj6E`Ut~c-11|qR4-=2FFgvwrIG3 zI8jvOm?wn9p;8fQi&UwH9C`u~4jd{tA=GG*P%9zC38e~^dZ<)pHg@AgLU7EJ_RaTx z=Dly;*z^0`mE-D`swxbT>EF&&Hae<_ZgoZv9ZSntPB0d-*o+Ii$jWDwv>Uq}pOEpS zRjfK#%xGyH>uE3cI<}PQv;zxi0~-nEv@3grmfkIvd|IDmEmw$no1f~2uxTld*G;`R z)U`a074G~sKET>?Dc>wND@$ctruw~l4N~Y!b^yUo=n7nqB%#R;^Cy|C3Liuw@HzXY zEMb8(z%F>mhI}%Cwot6r8)aK6RT|3>;|ls9O`s}BzQ$Q-b2IPrg1MmuK34C@dRx7q zUeq^at*u?qevtepJ|PL0y2uZ22wH%SYL8@To*!08G_)r~%x?4t zL4%;^hX-LD#pwmnn@WNqK7odb8G_eE6R;YGwrHR+xU0sX&dV^Tx!fbYqS+_mu6PiQ zzzIDLmo%-5e$-r0l+3_*FE6*VV*)zTI5~`;uSvNnP}RdulTo?9$s=WVlh@nNJS4xk*4;|Azm^O6l@j_4QCfANm)T_k7dviEg4Dit9>$$NfQ(KPhGhp`!=-c;?jo z$uqQv=Y4&D@m#y-yN*H$@|(pms^V0QQZ+_Z3U2!U^d2S9=9X8+@eFl7yLMwhMF+#! zO)dH^VETmET&?~t<{i{(7O{s0=$n8wxD&V)e2&1B*Li|$X&Ki`tzr|8z<0qQdIoL> z+sK47MncJxH2TkJsL(MEcxFGH{Yt*-x$ODbe+br%qq`hykBkF?7^XcUa5x%9QKHT1 z!a_>gCifx@y+o8PF0VApjixQ)e5FxtV39gyT(4FO`Qke%4iCZ?(ILX&o9Kv8A;DMR zkLbl<#FiEpOZDR7qAeF%m1?t6Yv2X=DE9QLFVoue;Le$Gi565E=c@T?@oaf%#d-8? zZO8Fc`FwG^ZNIaQm#{>O$RNgJCtMUdBXx$`H$z%pdaH82*}`RKh?q_aQ>tGxxInQ< zbL812GTLhSdEA0?@hEqj8HG+fIiVnP)!1Ulvc)m1catS(Gc<;h*QCE0vTUA0Ze(10 K;XXqar`|vB%a?=z delta 1832 zcmd5+O>A355Z-;in`iHPw&Q=tKS>;#6iV&1p-qE`f>aQ;R7jqHA{UV3x)8AAbnS?g z3lic`euJ4qj}R(FpW11~VL=ns?>g%iT(bM3=mlj$q~VK&ZNu@pgqKA+mfNwS z&EK*HZj4ZT)#!-vxiy2YA@4Q!}IZGi9b9wInZxt$yFkh};^H zId-^8m`8ON*ZjBQM6i&CMl8)Ca`&2~>pi*mvBuaDZMZE>Gcpm0nb z7Z>48H5M?t5j{QUg)OxozEaJAujlO|=X5aO@7g`xxpzu4;d|AOcUhJ+6Gt4rPX|mK zgEcLQ7Z2Z z-?#F+4^WRmc18Ai4C|hQ@)$O*`>x<< zi=iN%fXks(9D;B-Wjw>fhWV~zcrbien|^Ql>;5#Xgim(h#*Oe{Rf`DTDBOw1_+R`^ zJmQ%@Gp6rWa#|cc1lfe&r*haPtX8Sa7fUaW$t(y@CWd&TmlJ9A0zW(hR}*h>hD_3r z^Kd$OEdPkOExv;l#bu*@u~Mv*o-bD~IWxFz_8j+?S4zxDvDaB+Ri=siI((5#yg~$Y zn&Y%-Pg4ZFeqmvyvCPU&SD5&8Cd&E Session: yield session finally: session.close() + engine.dispose() def test_product_relationships(session: Session): @@ -42,7 +43,7 @@ def test_product_relationships(session: Session): stock_status="in_stock", fetch_method="http", fetch_status="success", - fetched_at=datetime.utcnow(), + fetched_at=datetime.now(timezone.utc), ) image = ProductImage(image_url="https://example.com/image.jpg", position=0) spec = ProductSpec(spec_key="Couleur", spec_value="Noir") @@ -52,7 +53,7 @@ def test_product_relationships(session: Session): reference="B08N5WRWNW", fetch_method="http", fetch_status="success", - fetched_at=datetime.utcnow(), + fetched_at=datetime.now(timezone.utc), duration_ms=1200, html_size_bytes=2048, errors={"items": []}, diff --git a/tests/db/test_repository.py b/tests/db/test_repository.py old mode 100755 new mode 100644 diff --git a/tests/scraping/__init__.py b/tests/scraping/__init__.py old mode 100755 new mode 100644 diff --git a/tests/scraping/__pycache__/__init__.cpython-313.pyc b/tests/scraping/__pycache__/__init__.cpython-313.pyc old mode 100755 new mode 100644 diff --git a/tests/scraping/__pycache__/test_http_fetch.cpython-313-pytest-9.0.2.pyc b/tests/scraping/__pycache__/test_http_fetch.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 diff --git a/tests/scraping/__pycache__/test_pipeline.cpython-313-pytest-9.0.2.pyc b/tests/scraping/__pycache__/test_pipeline.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 index 7b80cbd5a7bea73178cde1f29640d842122465ac..b0cbecc33d6dc3922ae4077298b5955c940b2d88 GIT binary patch delta 2374 zcma)7U2GIp6ux(6_NV*TpWSwU`Zu&yItG^79}BTy(Sm?w>F%%yE_B)M4%>xxX1Oy< zX$@J_#0T|3m^2|VLE}#%zCh9!O-zUnCPrUcD^Nz`ixSi516oW}H1VF@PFp~YH#^@w z_uPBVz31HT+`Zg;ebBSyb~_Ml>>JtK=dN>}0E6(k_CFkAO$d>uEF*Yl9>$9m_|i<3 z^9x+HlP)#b{A4G2no4B%%#SV~#{K6#0X(1wJ)7}fJ?6QHPtN@8vDx*%>H;pZi7s5+ z2&)-=oj=gf1L9_YCn)SE+bKpaVS~x3m-Hcj-PQq^+X1i(U^l=XfI)!004{(CfC#W3 zAP$h$-|;_%CuV-|-@;59>$f>y@i7jeVb?SgvB*4w_Fx(FD1cg!$cB)}#c>ZyEomEf z#a-Q4w1m)cyUfH<%u?2`MbR2(dYH0?M+uubC)z}NqGDxKAv#2-=%PE~9(}NSxQk8H znv)6tiWD>@(JT74V6m2dbz88^#kp9${zP>WlV0zub~coC2*uj;Ij>K@>~nZbFR40&5)Hc3 z+pJ&lRap07YFQfgT6=KWPHYS_8?6Y*cCkrpj@vtMX;hIAOuvcM9Udv|P^@(nO>+s0 z!L2xdO5{T*&c-&FMyq`+yegZL7XeS8$+pC!WeK6U&Gf2#U`CL6n})kiY)SCus#pt^ z^oups`qvLe&m=l@cWonvAe#b-4%5hVOf%3@%0N3)PK4kvr^MDUTMFIzAEEzL@j&QP z*`UxiwJ9!3yhMmpApF3SeCoJT&?LS9|2vJDdJTdgkSA>>)bTEw$BZ5UVLKqPfR%8g5Wd+XY)+AL+=5u*zEGJ9J%t(?R zLm|mjCZi@{^Kjpol1`1O+rk5d@#Sq64^fY#hC)QJ5HAd14+1?a)YIN@lT=QXlB%StIYl;l|9{B$7#^m+R1byU&Z3*1z=hzv zr*+oeS_)-qL!RW)(nLy2kA_qEy#8sZ#ud&=nz^g~XI-f1hSS)Fh>|Jfv@9La*49tD zXh*sMfY;^sZ#_srV*-`5_BkXPC{6+V!)ur}Z=KE~NGCX7g2HM=MV`_ij=X3Web!}4 zhB$tlPMkv179Q=T@1_&+D2sd9V>HiGbwd(SbE+oEX$eRvrAL*5Bt^<;XACCXVQ|0@ z70Af*r=$Cy%R|aM)*T3I=y>b&B z{I0?)_F|0>Pi;u4szfyYq4xY_x1c(nUl#bSB~^!@wu7cqPzA>sgH6fBNr5HsFGcoI zfWNA4A!vBilVhoJ4TYuCX)+0OHdWG6nns8nmcy0=xJ%$sCkFuNqld_|0C20M5ug&_ zAV8@=o&zZX0MsEzfRtnyAPI03fTv(sN@*moP+4#r&T&OPAx-9iOQqA07hq)uKmr&5 zI0gV5BBKB~3Wl>kKbb6NrK>zRrjYTJ)>E#E694!$t-pL{{QVbo|2>iO)O(zMYKGE_ z(*G70zQbXQ`}O15O2;QPv^g-ZW|OZdiHe_`M) zy(wUIMX%s_Z|8!?zu>9SI|A)h@1FVCHM{%B+~(oA^~t%)qxz8mDck@tQMwgS+Q92lFesHKS`>l$w=i=BfOEB~_McSZFqE$z(=J zCzCG2K2peyX*pRX3H`fZ7e1u>L*F`2LaHYK&g*wWjgB{|2nDCUs<+g?RP{?|E$&+C KsK+r}^7uFO_8{~C delta 343 zcmccSv)-KVGcPX}0}xp1r)R$6n8+u=^owDl#xIt1CQYHuI*czl88tVHai3-sa0M#5 z#avKnROAL^6?ts_$RoliWe*bND9%ePD9*?)xy4bCS&*8OnU`ARJXxO4g3)Jk1m8Ty zg3aIg*f|(AC;t+Yseh>p(#`}T3_ye-h%f>X#vsB3M3{pJJ0KBKEG6HdN>*T|#&0L?2L>Y~Xq<{hd)eKfq diff --git a/tests/scraping/__pycache__/test_pw_fetch.cpython-313-pytest-9.0.2.pyc b/tests/scraping/__pycache__/test_pw_fetch.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 diff --git a/tests/scraping/test_http_fetch.py b/tests/scraping/test_http_fetch.py old mode 100755 new mode 100644 diff --git a/tests/scraping/test_pipeline.py b/tests/scraping/test_pipeline.py old mode 100755 new mode 100644 index d0f1407..9acd517 --- a/tests/scraping/test_pipeline.py +++ b/tests/scraping/test_pipeline.py @@ -80,3 +80,33 @@ def test_pipeline_respects_disable_flag(): assert product_id is None with get_session(config) as session: assert session.query(Product).count() == 0 + + +def test_pipeline_db_error_adds_note(monkeypatch): + """Une erreur DB ajoute une note et retourne None.""" + from sqlalchemy.exc import SQLAlchemyError + + class DummyError(SQLAlchemyError): + pass + + def raise_session(*args, **kwargs): + raise DummyError("db down") + + monkeypatch.setattr("pricewatch.app.scraping.pipeline.get_session", raise_session) + + snapshot = ProductSnapshot( + source="amazon", + url="https://example.com/product", + fetched_at=datetime(2026, 1, 14, 13, 0, 0), + title="Produit", + price=10.0, + currency="EUR", + reference="B08PIPE", + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + pipeline = ScrapingPipeline(config=FakeAppConfig(db=FakeDbConfig(url="sqlite:///:memory:"))) + product_id = pipeline.process_snapshot(snapshot, save_to_db=True) + + assert product_id is None + assert any("Persistence DB echouee" in note for note in snapshot.debug.notes) diff --git a/tests/scraping/test_pw_fetch.py b/tests/scraping/test_pw_fetch.py old mode 100755 new mode 100644 diff --git a/tests/stores/__pycache__/test_amazon.cpython-313-pytest-9.0.2.pyc b/tests/stores/__pycache__/test_amazon.cpython-313-pytest-9.0.2.pyc old mode 100755 new mode 100644 diff --git a/tests/stores/__pycache__/test_price_parser.cpython-313-pytest-9.0.2.pyc b/tests/stores/__pycache__/test_price_parser.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..757eca1436b3bfd541fcec08724ed241cec25050 GIT binary patch literal 5261 zcmeHLPj4GV6rZ&x{9TM=VN0T1m)QuoAsY#2>r&Cpn%TH`5II|APo_uNm}foL}I*-FJl^0(DDdLD9jUo zm|RYg6kvP{k+e1fGNUCx%32cSsFngbrlmoSZ^m+&y-MbDi+WUXofc7SOK}ZC>&;E2 z29VTu6yqz)RHy}J!?Rk1GDo9s+jWajHS?T}ZY3xAS6Q5;Lx^eVp0(qdeB3>eggOqt z^D~J*hSWssfJCXH#U5_Lqd61R*ax2>o1L7TNyfckY7E%uZTp{~92(p~sp(r?R97OjVdV8p=~jax58kA@9HFerLe zf-&}=Y+zJ>c3#_33P$zXma(9vmQeBgFiTCWCACx)7bLEYtX~NR9_j09VC&aeR2>QW z3TjKzPzC;B_(>JXEpDP*dSx#)rxX{LW($SidGwFp-#e6E#443TX|FIfbumYCnQxSeu$FU_uv|LCejM5`QDkC)iSmnagHnrlyo042WyYn$G>`Bz z_uO3%vRyZbe@!L+XmT2TLnPPd$vUE@vlP21Tp zY`tkTEI+MNcC`AuM^Me{CkzO+n_=BY^xH_x6Ku;&o)@d*$- z<(ws|yX#L0YlOqwb#F`e;K`}ctWll1hG~%rxFKWG`F9{5qhD^n+s4Ib@>~1MU3nVd zL79t=Ts+0S>j)Ry@^q*QaLl7ZV<)15YkygIx{*-82jwsz1Shz74nsl1)rQSNzg+Uw zrp1=;xnCduB$ga90xDr_AuN0OVnEUijWfa4I>Ht=auY;^RYU>ZpBT)IrU6Xf)|(sD zB~#Fjt==@?#ho@TGhVEAEj50J#oB^>6CpV}dHq^KigdiM37Y9())GO@6XxBiC3=mY=(~4bR+C z4cAr0kz9?Q#>L@;9kltWg72aTdv)=JgU{5V*iDJkN$R79Q)}7Q$D{;}*oQP)1o3BF mlBB<4F$w>jLXz|?I!2$IB_wJ7EG0>Inc#?v{5h!Nyzmcg3>)GA literal 0 HcmV?d00001 diff --git a/tests/stores/test_price_parser.py b/tests/stores/test_price_parser.py new file mode 100644 index 0000000..a54d98d --- /dev/null +++ b/tests/stores/test_price_parser.py @@ -0,0 +1,29 @@ +""" +Tests pour le parsing de prix avec separateurs de milliers. +""" + +from pricewatch.app.stores.price_parser import parse_price_text + + +def test_parse_price_with_thousands_space(): + assert parse_price_text("1 259,00") == 1259.00 + + +def test_parse_price_with_narrow_nbsp(): + assert parse_price_text("1\u202f259,00") == 1259.00 + + +def test_parse_price_with_dot_thousands(): + assert parse_price_text("1.259,00") == 1259.00 + + +def test_parse_price_with_comma_thousands(): + assert parse_price_text("1,259.00") == 1259.00 + + +def test_parse_price_without_decimal(): + assert parse_price_text("1259") == 1259.00 + + +def test_parse_price_with_currency(): + assert parse_price_text("EUR 1 259,00") == 1259.00 diff --git a/tests/tasks/__pycache__/test_redis_errors.cpython-313-pytest-9.0.2.pyc b/tests/tasks/__pycache__/test_redis_errors.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..19e3af7c62bbc30c32bf962517d3a8cdcf4dd828 GIT binary patch literal 11370 zcmeHN-ESP%b)VUt{U*7j#4l5ly(a03n^;RmA}Pz2WYaQfh;pffqb=mn?O?b&T&=j= zUEkT2Ed~KtxJcw8h77c7l(uLEG!N!m@)q>T2J#1xCS|Gv2MN#sh2A95C;_1l{hfPf zW@ncxQA%8(1$4rPj|46 zgw!kbOKPxBQbS`tu^!vU>%ypuxOMw^T@-aOx2}`dwV_E)q=rN(Yy3wGfCNspD7iKMutXCGFD@UDU7W`GP)QFhsWwHN*6BzL-;;C9H1bGK(6c9cUSM zk&M8ofHr!i?S7PRNbgEa21!9m=2w01Dx*Ht-zQyGSU^=+Fc(M$t(G^dOG{VzCeAP3 zZNV@r$c##r6*IDDX3G2V+a`v&A?4(xZ|1_Pv?^PQX%rSrYEM`vhcA_ujNx3qP%zBl zGAmsMBFtfv-etH#$Q`ce<{}{S_L~@lBr{dMYAN)-R)=>(DXMD4+-3k^MY3K0WKCg*ph3{BS_{F=_1|N%wqmp!1XLj)aHkO1|%R?YI(|mPBTO*4MUSYMm^Z`045DeB=}1bD)YsB1^DT0SU2Ug5X#ID04UMf^Kpc~ zcXK}JL$4cBTAIbPF&Q#TppLyrAPykdN8p;1rL%Dd#0Oo}GX{Xw+xA`0f0Vx#7nrAs zR(%2~$uOgiteZd&fnESBGWkKqD3esO0ji{B7;UnV)(5GM03q9?9)i}rH_zS!t$RNm z{F)v+K>)Ejb*`g3@hYY5ZviyWJ*4{IRa8awIh4OFLmGpSMqe^)MfrPwuWD3{yU$_p zjHM70^Eb;7b)Do=n-;I9_Qgbjq>-nb#4!z;zl^dKt*MQqU$6nyvlM_y(kfqv>-hkgf7+w^V?=!b9#6$rE|YFJeuazp^wHxM9VweeSWmG0da zFvZLtg?TIR9+8vd#zFcxW_=XWd+K-KB0hmJ4&uA|eiE%AK0^}eq#!bc0Gs#bTvi}+ zP8w%K9=D9RjzI=8$a=A2rgc}}9nI zjG3h@RErr)(OJ&4f{X9-XEzGbK1*Ppj0cc=4Sl$L*Px7w7e8s2qMS~k(oEjGL zxw}IcZTYLL@U$9UNSw^x!URAFSMikhs(2yQy$x%+9G0Z+#7hwE{oNmj#hac*1tSC2 zs3<3?jsWf1Bs2>}*>m#@DaxK(Z<1~75wlRgZvbDdwD*Z}*~Zop%xXo%iKscwh}G%0 zO}unNs)<8d98KiR_U!3%cC}|uztd9|BJ)ykj!q&xdFGU3!^6D)rIJ~B zb^O@z6T>4Ha2)pK%~g4MdflU@FE5H zLS>dIEtPd z@A#h9er=(|mh=h^{^f#Rgrid`u7<4GQmMFTTrCr&tr#sQ_Wd1i;h48D?k$Xof|&Xh zv0qX6&d6>%Kkx(uWmkZgqxhNs2(TjkPKj?RJ?l!(TJKxyXEU2h_D=uFHDz)$_TpOq z$&J`4k~x~GUHOedN)JY$jntkWIgX1-J=Ia}3n z{xg*eiak@glJr=Wdx)!CqnI#Av(*&u6ttL;Vk&T0$5jDTaD7VNjaQ%647KW>V4Rf) z;RMfEzG4XnK=KQ%&RGf;;jrWlNHywS_UF{~^~Xu+0%B8DW_Y4byEX7h%wm!=zastJ z`OU#se$}}#czUz*HE~jT2Mtr~`+)3S0;C7oUl5olkO7!sS;`gxb}Nz8(`}Fa5VO2O zGyMyIN1xmYAOH98YhMTaf#AKcGypv^<4|Dsh zdx~h{I)(agc*Y|CS(iJ)&3vg$J<}}rGqOLZz#LDRyp+~Vr$R6TcwZ>=20nQzsPS4zP{Nx@y)1|Z$HJ}*oWB4 z1Z}68KAHG`qfh#jdTUa%Aw?OnTGt6A-V=#}MqX#}5xta}Yf^J8AJI23=emuxzHlDKDq&Sbzu< zASh4^6QsmVW-UlC2G2j@z6^r?e@PqF04N|Zw(N}(P(VRPOHzIqM2w+Ws$j9BPq-L* z^H2j8OvZWCkQuZTISo95&B!~of?|VgfvR%^E)mERxJ+P?K!Ly#fpLIjgq0|Njlf3) z$^hOdz$kmqtr6b;IIB>NteK?Dd8oe@?tlLABxavr$TWWDs|Z=HNWX9GTRSke(K@~| zb!T@lpjLDG*U?SooTtkhznjid~PQmkHtYBO*gd%JF z$U^9|I=7qf#NBSXGj5J6TwY&AV^U^6z@t%}S?x^En_f@ZdaZ%+c}UWu+%*0LMD6t3 zI05;~W-9=0659H2jc&9Jt%Zlg8JOY*?8g9f)AcqLxcm#L7ePi`z|oFOMtmSdJKz!R zzol9(-`A1jpILCh6?b$mJ<;CTPcf8~u{jG+C%X?6Igq>;lmhK;Ja6snJDbY)YXbUJ zB!2zS-yGWPdHHta$eot1>*qf@e@));OVQL;*o{TUR*rm&%@*4$MAUA=JCk#DG!fx2MpgJ zJ&L$O$7Ii5A>yS!AK#1P9Gg*tC`AO=n2TE z)#ltoYY3h*bGMkV8|cXXo&YhD)$Z=jo@v$ZkromZlavHxL`aB;Zaafm)0bmE$A|?Q zItNfEA8w$V2d0j5NAl~a)X{HSuX^DKcWj0G*Tem51Fx@rZ+4CH>TW^tbxa)ee||7J z@vTP)%2(LM|7}5W3paOw;`{0XhM5Bfuy)u{Vo%IP>S1mJlD-@u9}QVqiUA_wT(Y zwu(`7;xp-B|3|X*z;J@I)@_m-C7*@osvO;#9I1H2U!(>yGJcV!L#0O#~BMBGgU7;MQNcLCM z_4&tp5W>ci^%VhbI{5HNyyrwdNp;**U!fcYs(EyYAAq?f(k<98a@f zVi>77^Fx4pep#0Bk(8|Lge6)2Yw5$*-$-x%R+|1#X>naz+zI;R&ifL;PBb9*-j@J& z+WqoN_a%UxmY_U-Ujo?K=aYM=U(Zg|Cy!8WU=7lI~NGHr@f zX2~ShT@v-~nzMU3IBm~>uRwcgdj;Za3%DqXws$R%9u8>HyP{+yTe?LK2ypqw{mY4x zy6|6pZ+2&PNv%w|b=w?}5%oCV^LFOV%=^uoT4M1?bQm(V9D-flthjk=5$N6m&wD@~Ij&Ck+E8;uJ?uZ9q3G?LfPg4xn3X#(=WFfSbYRMSA{bI5n-rdh>3fba7H@gQCzzr zMRCL9dKHQIW;`*k?mw2kpi<6mw?eA!E|Yu)u=eX+eV1juI@RH39ji@V>w4>ra0oxjgMgM$A()ROb8;R`)?l?NBK$ zA}Q`Oo)MQKbqnVu7%C48m{0K%dBz8i*~NRLPQ-3+fy(Cld2;lHaxR)zU&LWjqbh+x z0uhep(@`owFT=CO2{N2v?0{cDKOOF7@LUz9n)4V--3*NG#C4IDiMa@7pneU@M5DAn za1?TdTnV~%JLja1(KyS}1LZUykOg6-t@GP5?X4Q&Lry3+u%-bwNiLX*IBNP;5Xg!7 zR;vw=#_6)AW?>e#Q^B^9=!_!~Y?jKWF@$uqx2?-9%H4J>f+N2mych1P>^QI(K3I_t z8pDf;6EQ#OfNz2lN;V-umUUk$l`UpKY&K(59};Yi;8@j*(l(4kf{7Yd^t$kK>A)RH z^lZBJe24wQWKT5&sIUu!SrMMKgA`Ob8=HvDWY_BOu31QM+ z3zS7S$vWN$QaaU|aF4i|h(?8kv{Q(ACUiHf@pRaAS1xrJvXNovM~?s30;~l7c#n(CNrAuz3>wKc0)nB3_{1SiRUxBP;a$$j~R!(sf_?Z z@ojx`^jhDw(pz7u^b9P82N%P`m)t9I)0NQW(6YR>DsR1(efyEhwxK0?cqP($DL}gq z#sx=%a!7~I7)sem=xwt8D3)=O^%y1~K}plPfxIq!=yH3uTvP4{@O2~m34VPNaC_Pe zdB@B(^3LSZX%Pjf#(j>;xunQ+DMg6~6^F`tnD!CddCBb|sC?ofO1SQ!;!OKY_p^PC zOZVf@+*u!ly>2 zzA)mo$+(ZoI6+2@rENd>3?UyG9Rmr%nM9sIG6p0j8v-8Y-IFTiW>sinn zSm`;vBq!ER1UiKHEU^1bz;1{;`9w^b02lK)&Wb+BL7qY4G$D>)0umHOsz{fXU8NAPOn9OMb1&2VyV9JbJ{w$xY{GZXbqnbPuNkwjSqn@4`9i$oVNuK!TOg z?VM^ruRm?fTTJ;MF&#ptAD^3pZ5&y~KG!`|6TUCgl&y7{eimvWNU%C8Ou%%o`*cO~~d6T$7QP(i9 zFNrguE^ji(;u0dPe*SS+!If}LhFGlitpwNvF1Qo!NmOy_TtQNT#nTDTp{E6*IK9VA z&HhjjlDJb-H;Fn;odYn~l<|qM;2y^trgU$ z>Rg|Y;M*+YEe-YBN}&wYIeV#RMc~PL66f3!>!pS{JuIgZiM#t@91A|BS!qf521Oc@ zlc=~+XR8Y};W`IA%B(K-t%^$PVX+_`7UxtL&uCo{2o}FPiR(vK` zA48|LJ5zhTZGYhBqC_fk0y zDP79uM97rt@B>J7nAOF;O{w;SWP9L00Mh-_nbuhM{3AyVR1nS0&cew@x&%*4+^#)y7l&h7;47vldN_){!I&1$WdpB74;$gpm`3%v>u%i*sYr&A zjNS{wL7GZRwA6u^hLHy*Zi~@(^@L1LF!6c~GNCJStXR3a1tQMHet_O7?Akp_ZFv>y2U+(jxkX`;8JK*;lO7C1+s`{&X`)tewW zb+(+#mvRL_iH7{>T`BEcv7FD+tf`rN@oYLz$5HpEwDh!!HR$2TX*aWTO1&*LT_m&V zQc8V!E}t&YBQZji-2r^e3qWEKJvdt|Tu?90;bbByOvocS1Vj%aGe#E-*#jf?zv;8<%#xH{v0{$=B(Nl%v_(AelZhos z`Jj3G+ns;=Ottyg!ZRy@w&lRCYG7C8ktY`ePc1z8elUDx?DAMe>Ur}^lvIN~%aZwd zSL(Sm#zThZCdBRMaaAjX31;R+tc~a49*ACvbX|Gv@@o~Te>t+d8rcn=H}+Q|yBC9d z!29;&lvIQL%aDP77N!15a1Rd}o|{;T$5m@WM)WPI|I$T!4isD z__rVDW$hl|&H%o+C@{zM^nZs5WnZd))%A2Gs5F5d?@ppPQS~#iy8t1bWZe6ag6#J9EQ4`@JGQ5nBv8Rk75cgixe$d?KmJ5K#T1O2@plc)Qk;Ld@7}) zVSn>Ov4%N)tWT9r6D`WhCZ#JOn|ihX0mg|V1Xic1S9?L9J`QM+V<~Iy7k@B`2j_LJ z;7i6>RDyF@2*NqQPZLdaWPibCyonYYk8KFXcJtKV+s3y0q0cSG6j#bw#D>--mYJ7^ zxz(*~c_1z~vMzDl#~P&P6;=;A&tYB)xZ2WCt}JBNs9rw44eR6>uwl+7CV|RU6D@|7 zZAwofY)WSEufR0B#Z_~1VX()}Kw#oqbd48}xFL=w#qmD;GmS^`GFOc_j| zg;Ja-AlwuglGEr*0I50Z+c?@IUu0F;upXI$NPVbIw`4doWiq!G<^2ZMMG5ZGB#Gn< zk}o599!VMrp6G*_JT9I~Ki&0Vy?pT)0&%4r&OONu9;kgNMBRsBZg)qU)yoj~&Jlq4(fXS94 zebq=`MH*g??5jrhfd^5U(J#X{S|EI*r5YSwh79zxC=FB2tr&>EiKTd!YE8(9z9kJK z4l_$Z4i8QqDp7A>eF7gnIH;R; zMTK4=3V+946E7Wh_X^V@+`N746kb%M*Y)ae$bz9KUC0_~(ZX)HC>othYteKjqr#>Z%GM{@A4bbsQnArG}ZG_?XR(J4Bki0-?breC4T{RqFG1#Czzi1v2m=(2YxOOee7|2 zymw^5)66U%8?bYH;E!C{_S@T*Htk#T_uuGRkz1DK9aVWpW#{8d@)LL6LLh#}D>SuL zq@injz8C*)96Zb2gVpXq@O&pz=^k9{90G5pbEp~|T82dWS(Jt@9WcC0mX{|(+;=iO zRILda(YLTYdkz-52?=o{0UCk!Eq|xgJ}(KLm0;VIBbSdD$5(T};e*k}LylFiEC|p08>{03!mK~xhfNi)q|0!Z$obxqC*fjti2u{O zP3|PW*>swpg2KI}z}kCDpaw(UB)36K@)LFF57U7f9Hcjwnrdz?g&yc65N!cgE1(Y6 zJ5X*pq+nYz+bF4WYnA3ouvLdsX;32{)Y^lJj@6DMmsXE*?ohe4N;z_8l>)B9jdCYA zglL$PtHE%;%1~eG?^nSy8O~OhqoUa0ewBL?#<1QgZEUQf!a7pd{+ZO(_gWqB`%LO+ z2Qf)9Pqd9&ea=$X!Sa44b%tzxw${PTQaSmM);h4Cbk2{SN?UJ+)xa&dt8_M>QvrzW zPM5*DqR!^#+_fzCR^{He+)MJ#_aa@_+P?K_Mc!uMIW#3Xf;!}i*-Zu!8adoX10YA? z8^Ao<6*K^BhTY-@R8eD=p@{4cA$Hz=RSqy8`5r`0SjM9Ht@pXMHw>8NS>)~%5(ndl z(Yl3$>L3t>MPI?PUqymQDi*NWH2xA}^GGPN`2qUSjA6HK9Iyw*2|lTYaG6^#K5vX? z&;7@b{56!py0xdFzhSWIuWPMr+FX&^@x{!Z8U%CvpL>?1Dceo04M3WY0k1?Vk>15% zv?A?#3$9850h7kEWPTPUuxdnk(D2-pcHyDCnWb72GNNxmu?QdpD+>=oVO}HD{ubPd zvYIpe5Dh@e6JC~jt5R^lJskzMn0FMU36yGENdJ-Z3QvAiZ5|E zENY6F#7R$`1&S0`K}_P219b`h5^Dk8IxG$++@}QV0#|k!)NC{{Qa1@ut=<7pk?5rv z&(sdvS*JINlZU!V4l!7a zCuah&;QXlbi~DdLp#)ZYcrW`jDXUQ}XYRLfCAV0}#_62|dQagrSo&Q#?YcNmw+)9A zHMVUS_Ix}!GtKG*C5HSFl0QcBCqO1*L1T?a2{AHkaAm*lGZyt%vETv{dLTvD^RHp> z&wkjSlpKXRKv+wskR*{Y_1-DoF z9$O3^2LC(l1_>Trmdww)($PxrFb^7@o3t2@tlEN*1l1Wyi?*`hf2W<7;GSF3(a%-_ zVeMT0Y$e#>N^sbD5?Cw2v2-3@Y~f1*tPRF$fYuJ%ig1kl4a^eqcS!Cx!>JjAX4v2i zCqKj~h@0ESXYe%qlu4PW(y1oyNHRA?{c`0vk!= z)6jKJKmQ-!?X*r3H-0$*PH5M;oM7BEhpPx7{Iisonb7*iyqS<`$_vjQs=q8{{0ND& zZI6q4w)hhK9Ek#>@Q|+NU1L?PG2@Na!3fT;Vb5E~h=-_z;09$ty(Ie1Ao((q=aHm= z+{WWj@@*g!fP?OjtiF#~{}c(TgyeUDz+3XKpg)BKVN=cTU+Bhm$`~g67jw*C2BNH! zA44(Rmo?m>*P? zV6W=UbS&dw#Z>3$ulf)P({^E6&;4JAGX?|K1o}>vvB*+=i7u(u)xcAjg^uuL^j$@Q z#x#mUsZvANMLHhzRdqvhS|AI&4`}>C(Gl`55QJq%(EbX@9k(coAG%zkbXOKc@f*TT zVf!zI&@Y6i?n)lfwJHF)(=51xcU>N_{SE}%SKSc2gTcGaK5_g*0m$8Uzu5Mn0OW2L z^WEv9#Y8c52Wk$P#V{zF30p8>3s2bck=!K4Kk5mI`#<_zi@5KjJze4>AB{=kG4Z3Y QS3EARwuZ%oNFVCI0Qr(w-T(jq literal 0 HcmV?d00001 diff --git a/tests/tasks/__pycache__/test_scrape_task.cpython-313-pytest-9.0.2.pyc b/tests/tasks/__pycache__/test_scrape_task.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67f2b662be075f1c5b0232dabb5de54b03394989 GIT binary patch literal 8803 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 literal 0 HcmV?d00001 diff --git a/tests/tasks/__pycache__/test_worker_end_to_end.cpython-313-pytest-9.0.2.pyc b/tests/tasks/__pycache__/test_worker_end_to_end.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b41e1eb73880d930b0b65e8f75c4535d61d2198a GIT binary patch literal 9367 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 literal 0 HcmV?d00001 diff --git a/tests/tasks/test_redis_errors.py b/tests/tasks/test_redis_errors.py new file mode 100644 index 0000000..d20ba49 --- /dev/null +++ b/tests/tasks/test_redis_errors.py @@ -0,0 +1,127 @@ +""" +Tests pour la gestion des erreurs Redis dans le scheduler. +""" + +import pytest +from redis.exceptions import ConnectionError as RedisConnectionError +from redis.exceptions import RedisError, TimeoutError as RedisTimeoutError + +from pricewatch.app.tasks.scheduler import RedisUnavailableError, ScrapingScheduler, check_redis_connection + + +class DummyRedisOk: + def ping(self) -> bool: + return True + + +class DummyRedisError: + def __init__(self, exc: Exception) -> None: + self._exc = exc + + def ping(self) -> None: + raise self._exc + + +class DummyQueue: + def __init__(self, name: str, connection=None) -> None: + self.name = name + self.connection = connection + + +class DummyScheduler: + def __init__(self, queue=None, connection=None) -> None: + self.queue = queue + self.connection = connection + + def schedule(self, scheduled_time, func, args=None, kwargs=None, interval=None, repeat=None): + return type("Job", (), {"id": "job-redis"})() + + +class FakeRedisConfig: + def __init__(self, url: str) -> None: + self.url = url + + +class FakeAppConfig: + def __init__(self, redis_url: str) -> None: + self.redis = FakeRedisConfig(redis_url) + + +def test_check_redis_connection_success(monkeypatch): + """Ping OK retourne True.""" + monkeypatch.setattr("pricewatch.app.tasks.scheduler.redis.from_url", lambda url: DummyRedisOk()) + assert check_redis_connection("redis://localhost:6379/0") is True + + +def test_check_redis_connection_failure_connection(monkeypatch): + """Ping en echec retourne False.""" + monkeypatch.setattr( + "pricewatch.app.tasks.scheduler.redis.from_url", + lambda url: DummyRedisError(RedisConnectionError("no")), + ) + assert check_redis_connection("redis://localhost:6379/0") is False + + +def test_check_redis_connection_failure_timeout(monkeypatch): + """Timeout Redis retourne False.""" + monkeypatch.setattr( + "pricewatch.app.tasks.scheduler.redis.from_url", + lambda url: DummyRedisError(RedisTimeoutError("timeout")), + ) + assert check_redis_connection("redis://localhost:6379/0") is False + + +def test_scheduler_lazy_connection(monkeypatch): + """La connexion Redis est lazy.""" + config = FakeAppConfig("redis://localhost:6379/0") + monkeypatch.setattr("pricewatch.app.tasks.scheduler.redis.from_url", lambda url: DummyRedisOk()) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Queue", DummyQueue) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Scheduler", DummyScheduler) + + scheduler = ScrapingScheduler(config=config) + assert scheduler._redis is None + + _ = scheduler.queue + assert scheduler._redis is not None + + +def test_scheduler_redis_connection_error(monkeypatch): + """Une erreur de connexion leve RedisUnavailableError.""" + config = FakeAppConfig("redis://localhost:6379/0") + + def raise_connection(url): + raise RedisConnectionError("no") + + monkeypatch.setattr("pricewatch.app.tasks.scheduler.redis.from_url", raise_connection) + + scheduler = ScrapingScheduler(config=config) + with pytest.raises(RedisUnavailableError): + _ = scheduler.queue + + +def test_scheduler_schedule_redis_error(monkeypatch): + """Une erreur Redis leve RedisUnavailableError lors du schedule.""" + config = FakeAppConfig("redis://localhost:6379/0") + + monkeypatch.setattr( + "pricewatch.app.tasks.scheduler.redis.from_url", + lambda url: DummyRedisError(RedisError("boom")), + ) + + scheduler = ScrapingScheduler(config=config) + with pytest.raises(RedisUnavailableError): + scheduler.schedule_product("https://example.com/product", interval_hours=1) + + +def test_scheduler_enqueue_redis_error(monkeypatch): + """Une erreur Redis leve RedisUnavailableError lors de l'enqueue.""" + config = FakeAppConfig("redis://localhost:6379/0") + + monkeypatch.setattr( + "pricewatch.app.tasks.scheduler.redis.from_url", + lambda url: DummyRedisError(RedisError("boom")), + ) + + scheduler = ScrapingScheduler(config=config) + with pytest.raises(RedisUnavailableError): + scheduler.enqueue_immediate("https://example.com/product") diff --git a/tests/tasks/test_scheduler.py b/tests/tasks/test_scheduler.py new file mode 100644 index 0000000..599e84a --- /dev/null +++ b/tests/tasks/test_scheduler.py @@ -0,0 +1,184 @@ +""" +Tests pour ScrapingScheduler avec mocks Redis/RQ. +""" + +from dataclasses import dataclass + +import pytest +from redis.exceptions import ConnectionError as RedisConnectionError + +from pricewatch.app.tasks.scheduler import ( + RedisUnavailableError, + ScheduledJobInfo, + ScrapingScheduler, + check_redis_connection, +) + + +@dataclass +class FakeRedis: + url: str + + def ping(self): + """Simule un ping reussi.""" + return True + + +class FakeRedisConnectionError: + """FakeRedis qui leve une erreur a la connexion.""" + + def __init__(self, url: str): + self.url = url + + def ping(self): + raise RedisConnectionError("Connection refused") + + +class DummyQueue: + def __init__(self, name: str, connection=None) -> None: + self.name = name + self.connection = connection + self.enqueued = [] + + def enqueue(self, func, *args, **kwargs): + job = type("Job", (), {"id": "job-123"})() + self.enqueued.append((func, args, kwargs)) + return job + + +class DummyScheduler: + def __init__(self, queue=None, connection=None) -> None: + self.queue = queue + self.connection = connection + self.scheduled = [] + + def schedule(self, scheduled_time, func, args=None, kwargs=None, interval=None, repeat=None): + job = type("Job", (), {"id": "job-456"})() + self.scheduled.append((scheduled_time, func, args, kwargs, interval, repeat)) + return job + + +@dataclass +class FakeRedisConfig: + url: str + + +@dataclass +class FakeAppConfig: + redis: FakeRedisConfig + + +def test_scheduler_enqueue_immediate(monkeypatch): + """Enqueue immediate utilise la queue RQ.""" + config = FakeAppConfig(redis=FakeRedisConfig(url="redis://localhost:6379/0")) + + monkeypatch.setattr("pricewatch.app.tasks.scheduler.redis.from_url", lambda url: FakeRedis(url)) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Queue", DummyQueue) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Scheduler", DummyScheduler) + + scheduler = ScrapingScheduler(config=config, queue_name="default") + job = scheduler.enqueue_immediate("https://example.com/product") + + assert job.id == "job-123" + assert len(scheduler.queue.enqueued) == 1 + + +def test_scheduler_schedule_product(monkeypatch): + """Schedule product cree un job recurrent.""" + config = FakeAppConfig(redis=FakeRedisConfig(url="redis://localhost:6379/0")) + + monkeypatch.setattr("pricewatch.app.tasks.scheduler.redis.from_url", lambda url: FakeRedis(url)) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Queue", DummyQueue) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Scheduler", DummyScheduler) + + scheduler = ScrapingScheduler(config=config, queue_name="default") + info = scheduler.schedule_product("https://example.com/product", interval_hours=1) + + assert isinstance(info, ScheduledJobInfo) + assert info.job_id == "job-456" + assert len(scheduler.scheduler.scheduled) == 1 + + +# ============================================================================ +# Tests gestion erreurs Redis +# ============================================================================ + + +def test_scheduler_redis_connection_error(monkeypatch): + """Leve RedisUnavailableError quand Redis n'est pas accessible.""" + config = FakeAppConfig(redis=FakeRedisConfig(url="redis://localhost:6379/0")) + + monkeypatch.setattr( + "pricewatch.app.tasks.scheduler.redis.from_url", + lambda url: FakeRedisConnectionError(url), + ) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Queue", DummyQueue) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Scheduler", DummyScheduler) + + scheduler = ScrapingScheduler(config=config, queue_name="default") + + with pytest.raises(RedisUnavailableError) as exc_info: + scheduler.enqueue_immediate("https://example.com/product") + + assert "Redis" in str(exc_info.value.message) + assert exc_info.value.cause is not None + + +def test_scheduler_lazy_connection(monkeypatch): + """La connexion Redis n'est etablie qu'au premier appel.""" + config = FakeAppConfig(redis=FakeRedisConfig(url="redis://localhost:6379/0")) + connection_calls = [] + + def track_from_url(url): + connection_calls.append(url) + return FakeRedis(url) + + monkeypatch.setattr("pricewatch.app.tasks.scheduler.redis.from_url", track_from_url) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Queue", DummyQueue) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Scheduler", DummyScheduler) + + scheduler = ScrapingScheduler(config=config, queue_name="default") + + # Pas de connexion a la creation + assert len(connection_calls) == 0 + + # Connexion au premier appel + scheduler.enqueue_immediate("https://example.com/product") + assert len(connection_calls) == 1 + + # Pas de nouvelle connexion au deuxieme appel + scheduler.enqueue_immediate("https://example.com/product2") + assert len(connection_calls) == 1 + + +def test_check_redis_connection_success(monkeypatch): + """check_redis_connection retourne True si Redis repond.""" + monkeypatch.setattr("pricewatch.app.tasks.scheduler.redis.from_url", FakeRedis) + + assert check_redis_connection("redis://localhost:6379/0") is True + + +def test_check_redis_connection_failure(monkeypatch): + """check_redis_connection retourne False si Redis ne repond pas.""" + monkeypatch.setattr( + "pricewatch.app.tasks.scheduler.redis.from_url", FakeRedisConnectionError + ) + + assert check_redis_connection("redis://localhost:6379/0") is False + + +def test_scheduler_schedule_redis_error(monkeypatch): + """schedule_product leve RedisUnavailableError si Redis down.""" + config = FakeAppConfig(redis=FakeRedisConfig(url="redis://localhost:6379/0")) + + monkeypatch.setattr( + "pricewatch.app.tasks.scheduler.redis.from_url", + lambda url: FakeRedisConnectionError(url), + ) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Queue", DummyQueue) + monkeypatch.setattr("pricewatch.app.tasks.scheduler.Scheduler", DummyScheduler) + + scheduler = ScrapingScheduler(config=config, queue_name="default") + + with pytest.raises(RedisUnavailableError): + scheduler.schedule_product("https://example.com/product", interval_hours=24) diff --git a/tests/tasks/test_scrape_task.py b/tests/tasks/test_scrape_task.py new file mode 100644 index 0000000..9df90e7 --- /dev/null +++ b/tests/tasks/test_scrape_task.py @@ -0,0 +1,91 @@ +""" +Tests end-to-end pour la tache RQ de scraping avec persistence DB. +""" + +from dataclasses import dataclass +from datetime import datetime + +from pricewatch.app.core.registry import get_registry +from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot +from pricewatch.app.db.connection import get_session, init_db, reset_engine +from pricewatch.app.db.models import Product, ScrapingLog +from pricewatch.app.stores.base import BaseStore +from pricewatch.app.tasks import scrape as scrape_task + + +@dataclass +class FakeDbConfig: + url: str + + +@dataclass +class FakeAppConfig: + db: FakeDbConfig + debug: bool = False + enable_db: bool = True + default_use_playwright: bool = False + default_playwright_timeout: int = 1000 + + +class DummyStore(BaseStore): + def __init__(self) -> None: + super().__init__(store_id="dummy") + + def match(self, url: str) -> float: + return 1.0 if "example.com" in url else 0.0 + + def canonicalize(self, url: str) -> str: + return url + + def extract_reference(self, url: str) -> str | None: + return "REF-TEST" + + def parse(self, html: str, url: str) -> ProductSnapshot: + return ProductSnapshot( + source=self.store_id, + url=url, + fetched_at=datetime(2026, 1, 14, 10, 0, 0), + title="Produit test", + price=19.99, + currency="EUR", + reference="REF-TEST", + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + +class DummyFetchResult: + def __init__(self, html: str) -> None: + self.success = True + self.html = html + self.error = None + self.duration_ms = 123 + + +def test_scrape_product_persists_db(tmp_path, monkeypatch): + """La tache scrape_product persiste en DB et logge un scraping.""" + reset_engine() + db_path = tmp_path / "scrape.db" + config = FakeAppConfig(db=FakeDbConfig(url=f"sqlite:///{db_path}")) + init_db(config) + + registry = get_registry() + previous_stores = list(registry._stores) + registry._stores = [] + registry.register(DummyStore()) + + monkeypatch.setattr(scrape_task, "get_config", lambda: config) + monkeypatch.setattr(scrape_task, "setup_stores", lambda: None) + monkeypatch.setattr(scrape_task, "fetch_http", lambda url: DummyFetchResult("")) + + try: + result = scrape_task.scrape_product("https://example.com/product", save_db=True) + finally: + registry._stores = previous_stores + reset_engine() + + assert result["success"] is True + assert result["product_id"] is not None + + with get_session(config) as session: + assert session.query(Product).count() == 1 + assert session.query(ScrapingLog).count() == 1 diff --git a/tests/tasks/test_worker_end_to_end.py b/tests/tasks/test_worker_end_to_end.py new file mode 100644 index 0000000..bdc592d --- /dev/null +++ b/tests/tasks/test_worker_end_to_end.py @@ -0,0 +1,110 @@ +""" +Test end-to-end: enqueue -> worker -> DB via Redis. +""" + +from dataclasses import dataclass +from datetime import datetime + +import pytest +import redis +from rq import Queue +from rq.worker import SimpleWorker + +from pricewatch.app.core.registry import get_registry +from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot +from pricewatch.app.db.connection import get_session, init_db, reset_engine +from pricewatch.app.db.models import Product, ScrapingLog +from pricewatch.app.stores.base import BaseStore +from pricewatch.app.tasks import scrape as scrape_task + + +@dataclass +class FakeDbConfig: + url: str + + +@dataclass +class FakeAppConfig: + db: FakeDbConfig + debug: bool = False + enable_db: bool = True + default_use_playwright: bool = False + default_playwright_timeout: int = 1000 + + +class DummyStore(BaseStore): + def __init__(self) -> None: + super().__init__(store_id="dummy") + + def match(self, url: str) -> float: + return 1.0 if "example.com" in url else 0.0 + + def canonicalize(self, url: str) -> str: + return url + + def extract_reference(self, url: str) -> str | None: + return "REF-WORKER" + + def parse(self, html: str, url: str) -> ProductSnapshot: + return ProductSnapshot( + source=self.store_id, + url=url, + fetched_at=datetime(2026, 1, 14, 11, 0, 0), + title="Produit worker", + price=29.99, + currency="EUR", + reference="REF-WORKER", + debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS), + ) + + +class DummyFetchResult: + def __init__(self, html: str) -> None: + self.success = True + self.html = html + self.error = None + self.duration_ms = 50 + + +def _redis_available(redis_url: str) -> bool: + try: + conn = redis.from_url(redis_url) + conn.ping() + return True + except Exception: + return False + + +@pytest.mark.skipif(not _redis_available("redis://localhost:6379/0"), reason="Redis indisponible") +def test_enqueue_worker_persists_db(tmp_path, monkeypatch): + """Le job enqueued est traite par le worker et persiste en DB.""" + reset_engine() + db_path = tmp_path / "worker.db" + config = FakeAppConfig(db=FakeDbConfig(url=f"sqlite:///{db_path}")) + init_db(config) + + registry = get_registry() + previous_stores = list(registry._stores) + registry._stores = [] + registry.register(DummyStore()) + + monkeypatch.setattr(scrape_task, "get_config", lambda: config) + monkeypatch.setattr(scrape_task, "setup_stores", lambda: None) + monkeypatch.setattr(scrape_task, "fetch_http", lambda url: DummyFetchResult("")) + + redis_conn = redis.from_url("redis://localhost:6379/0") + queue = Queue("default", connection=redis_conn) + + try: + job = queue.enqueue(scrape_task.scrape_product, "https://example.com/product", save_db=True) + worker = SimpleWorker([queue], connection=redis_conn) + worker.work(burst=True) + finally: + registry._stores = previous_stores + reset_engine() + + assert job.is_finished + + with get_session(config) as session: + assert session.query(Product).count() == 1 + assert session.query(ScrapingLog).count() == 1 diff --git a/webui/Dockerfile b/webui/Dockerfile new file mode 100644 index 0000000..1c0b9a3 --- /dev/null +++ b/webui/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +COPY package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..2b4229a --- /dev/null +++ b/webui/index.html @@ -0,0 +1,13 @@ + + + + + + PriceWatch Web UI + + + +

+ + + diff --git a/webui/nginx.conf b/webui/nginx.conf new file mode 100644 index 0000000..f57fbc1 --- /dev/null +++ b/webui/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + + location /api/ { + proxy_pass http://api:8000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..6ecd941 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,22 @@ +{ + "name": "pricewatch-webui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.2", + "vue": "^3.4.27" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "vite": "^5.4.2" + } +} diff --git a/webui/postcss.config.js b/webui/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/webui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/webui/public/favicon.svg b/webui/public/favicon.svg new file mode 100644 index 0000000..d4a38d9 --- /dev/null +++ b/webui/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/webui/src/App.vue b/webui/src/App.vue new file mode 100644 index 0000000..5d4e047 --- /dev/null +++ b/webui/src/App.vue @@ -0,0 +1,1566 @@ + + + diff --git a/webui/src/index.css b/webui/src/index.css new file mode 100644 index 0000000..5176bba --- /dev/null +++ b/webui/src/index.css @@ -0,0 +1,281 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light dark; +} + +.app-root { + --bg: #282828; + --surface: #3c3836; + --surface-2: #504945; + --text: #ebdbb2; + --muted: #a89984; + --accent: #fe8019; + --danger: #fb4934; + --success: #b8bb26; + --warning: #fabd2f; + --shadow: rgba(0, 0, 0, 0.45); + --radius: 14px; + --font-title: "Space Mono", "JetBrains Mono", "Fira Code", monospace; + --font-body: "JetBrains Mono", "Fira Code", "IBM Plex Mono", "SFMono-Regular", Menlo, monospace; + --font-mono: "JetBrains Mono", "Fira Code", "IBM Plex Mono", "SFMono-Regular", Menlo, monospace; + --font-size: 16px; + background: var(--bg); + color: var(--text); + min-height: 100vh; + font-family: var(--font-body); + font-size: var(--font-size); +} + +.app-root.theme-gruvbox-dark { + --bg: #282828; + --surface: #3c3836; + --surface-2: #504945; + --text: #ebdbb2; + --muted: #a89984; + --accent: #fe8019; + --danger: #fb4934; + --success: #b8bb26; + --warning: #fabd2f; + --shadow: rgba(0, 0, 0, 0.45); +} + +.app-root.theme-gruvbox-light { + --bg: #fbf1c7; + --surface: #f2e5bc; + --surface-2: #ebdbb2; + --text: #3c3836; + --muted: #7c6f64; + --accent: #d65d0e; + --danger: #cc241d; + --success: #98971a; + --warning: #d79921; + --shadow: rgba(60, 56, 54, 0.25); +} + +.app-root.theme-monokai-dark { + --bg: #1f1f1b; + --surface: #272822; + --surface-2: #3b3c35; + --text: #f8f8f2; + --muted: #9b9a84; + --accent: #f92672; + --danger: #fd5ff1; + --success: #a6e22e; + --warning: #fd971f; + --shadow: rgba(0, 0, 0, 0.55); +} + +.app-root.theme-monokai-light { + --bg: #f8f8f2; + --surface: #e8e8e3; + --surface-2: #dcdcd2; + --text: #272822; + --muted: #75715e; + --accent: #f92672; + --danger: #c0005f; + --success: #2d8f2d; + --warning: #fd971f; + --shadow: rgba(39, 40, 34, 0.2); +} + +.app-header { + position: sticky; + top: 0; + z-index: 40; + background: linear-gradient(90deg, var(--surface), var(--surface-2)); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: 0 10px 24px var(--shadow); +} + +.vintage-shadow { + box-shadow: 0 14px 28px var(--shadow); +} + +.icon-btn { + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--surface-2); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease, background 0.15s ease; +} + +.icon-btn:hover { + background: var(--accent); + color: #1b1b1b; + transform: translateY(-1px); +} + +.icon-btn:active { + transform: translateY(1px); +} + +.pill { + border-radius: 999px; + padding: 4px 10px; + font-size: 0.75rem; + background: var(--surface-2); + color: var(--muted); +} + +.panel { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.card { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 24px var(--shadow); +} + +.card-accent { + border: 1px solid rgba(254, 128, 25, 0.5); + box-shadow: 0 10px 30px rgba(254, 128, 25, 0.2); +} + +.density-dense .card { + padding: 12px; +} + +.density-comfort .card { + padding: 20px; +} + +.section-title { + font-family: var(--font-title); + letter-spacing: 0.5px; +} + +.label { + font-size: 0.8rem; + color: var(--muted); +} + +.input { + width: 100%; + background: var(--surface-2); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 8px 10px; + color: var(--text); +} + +.input:focus { + outline: 2px solid rgba(254, 128, 25, 0.4); +} + +.sidebar { + width: 280px; + min-width: 240px; +} + +.detail-panel { + width: 320px; + min-width: 280px; +} + +.image-toggle { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 2px; + background: transparent; + cursor: pointer; + transition: transform 0.15s ease, background 0.15s ease, border 0.15s ease; +} + +.image-toggle:hover { + border-color: rgba(254, 128, 25, 0.8); + transform: translateY(-1px); +} + +.image-toggle.selected { + background: rgba(254, 128, 25, 0.15); + border-color: rgba(254, 128, 25, 0.9); + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); +} + +.log-status-panel { + border-color: rgba(255, 255, 255, 0.1); +} + +.log-entry { + transition: background 0.2s ease; +} + +.log-entry-error { + border-color: rgba(251, 73, 52, 0.7); + background: rgba(251, 73, 52, 0.07); + color: var(--danger); +} + +.detail-popup { + border-radius: calc(var(--radius) * 1.2); + border-width: 1px; + max-height: calc(100vh - 60px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6); +} + +.view-mode-btn.active-view { + background: var(--accent); + color: #1b1b1b; +} + +.app-root.layout-compact .sidebar, +.app-root.layout-compact .detail-panel { + display: none; +} + +.app-root.layout-compact .product-grid { + grid-template-columns: 1fr; +} + +.app-root.layout-wide .sidebar { + width: 320px; +} + +.app-root.layout-wide .detail-panel { + width: 360px; +} + +.compare-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; +} + +@media (max-width: 1024px) { + .sidebar { + display: none; + } + .detail-panel { + display: none; + } +} + +@media (max-width: 640px) { + .app-header .toolbar-text { + display: none; + } + .icon-btn { + width: 36px; + height: 36px; + } + .product-grid { + grid-template-columns: 1fr; + } +} diff --git a/webui/src/main.js b/webui/src/main.js new file mode 100644 index 0000000..0da401f --- /dev/null +++ b/webui/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./index.css"; +import "@fortawesome/fontawesome-free/css/all.min.css"; + +createApp(App).mount("#app"); diff --git a/webui/tailwind.config.js b/webui/tailwind.config.js new file mode 100644 index 0000000..c5c731f --- /dev/null +++ b/webui/tailwind.config.js @@ -0,0 +1,7 @@ +export default { + content: ["./index.html", "./src/**/*.{vue,js,ts}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/webui/vite.config.js b/webui/vite.config.js new file mode 100644 index 0000000..bdd03aa --- /dev/null +++ b/webui/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000, + }, +});

3Cs1$4f3^ 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 Database (Phase 2) + +Guide pour migrer des resultats JSON existants (Phase 1) vers PostgreSQL (Phase 2). + +## Prerequis + +- PostgreSQL + Redis operationnels +- Dependencies installees (`pip install -e .`) +- Migration DB appliquee (`alembic upgrade head`) + +## 1) Verifier la configuration + +Copier l'exemple et ajuster les identifiants si besoin: + +```bash +cp .env.example .env +``` + +Verifier la configuration: + +```bash +pricewatch doctor +``` + +## 2) Initialiser la base + +Si la base n'est pas encore initialisee: + +```bash +pricewatch upgrade +``` + +Verifier les tables: + +```bash +psql -h localhost -U pricewatch pricewatch +\dt +``` + +## 3) Migrer un fichier JSON existant + +Le JSON de Phase 1 est deja conforme au schema `ProductSnapshot`. Il suffit de le recharger puis de repasser par la persistence. + +### Option A: Script rapide + +Creer un petit script ad-hoc (exemple): + +```python +from pricewatch.app.core.io import read_json_results +from pricewatch.app.scraping.pipeline import ScrapingPipeline + +snapshots = read_json_results("scraped_store.json") + +pipeline = ScrapingPipeline() +for snapshot in snapshots: + pipeline.process_snapshot(snapshot, save_to_db=True) +``` + +Execution: + +```bash +python migrate_json.py +``` + +### Option B: Enqueue via worker + +Si vous voulez traiter les snapshots via worker, utilisez une boucle qui enqueue `scrape_product` avec l'URL du snapshot, puis laissez le worker tourner. Cela garantira un refresh complet (fetch + parse + DB) au lieu d'inserer uniquement le JSON. + +## 4) Verifier les donnees + +```bash +psql -h localhost -U pricewatch pricewatch +SELECT COUNT(*) FROM products; +SELECT COUNT(*) FROM price_history; +SELECT COUNT(*) FROM scraping_logs; +``` + +## 5) Notes importantes + +- Si `reference` est absente, la persistence du produit est ignoree, mais un `ScrapingLog` est cree. +- La contrainte d'unicite `(source, reference)` evite les doublons. +- Les images/specs sont synchronises par ajout/ups ert (pas de suppression automatique). +- En cas d'erreur DB, le snapshot est conserve et une note est ajoutee dans `snapshot.debug.notes`. diff --git a/PHASE_1_COMPLETE.md b/PHASE_1_COMPLETE.md old mode 100755 new mode 100644 diff --git a/PHASE_2_PROGRESS.md b/PHASE_2_PROGRESS.md old mode 100755 new mode 100644 index 1408c7f..4f796b5 --- a/PHASE_2_PROGRESS.md +++ b/PHASE_2_PROGRESS.md @@ -8,17 +8,28 @@ ## 📊 Vue d'Ensemble +### Mises a jour recentes +- Migration Alembic corrigee (down_revision sur 20260114_02) +- Extraction images Amazon amelioree (data-a-dynamic-image + filtre logos) +- Nouveau scraping de validation (URL Amazon ASUS A16) + +### Prochaines actions +- Verifier l'affichage des images, description, specs, msrp et reduction dans le Web UI +- Confirmer que le popup ajout produit affiche toutes les donnees du preview + ### Objectifs Phase 2 - ✅ Configuration centralisée (database, Redis, app) - ✅ Modèles SQLAlchemy ORM (5 tables) - ✅ Connexion base de données (init_db, get_session) - ✅ Migrations Alembic -- ⏳ Repository pattern (CRUD) -- ⏳ Worker RQ pour scraping asynchrone -- ⏳ Scheduler pour jobs récurrents -- ✅ CLI étendu (commandes DB) +- ✅ Repository pattern (CRUD) +- ✅ Worker RQ pour scraping asynchrone +- ✅ Scheduler pour jobs récurrents +- ✅ CLI étendu (commandes DB + worker) - ✅ Docker Compose (PostgreSQL + Redis) -- ⏳ Tests complets +- ✅ Gestion erreurs Redis +- ✅ Logs d'observabilité jobs +- ⏳ Tests end-to-end (Semaine 4) --- @@ -226,7 +237,7 @@ PW_ENABLE_WORKER=true --- -## 📦 Semaine 2: Repository & Pipeline (EN COURS) +## 📦 Semaine 2: Repository & Pipeline (TERMINEE) ### Tâches Prévues @@ -279,7 +290,7 @@ PW_ENABLE_WORKER=true --- -## 📦 Semaine 3: Worker Infrastructure (EN COURS) +## 📦 Semaine 3: Worker Infrastructure (TERMINEE) ### Tâches Prévues @@ -313,22 +324,73 @@ pricewatch schedule --interval 24 # Scrape quotidien **Statut**: ✅ Terminé +#### Tests worker + scheduler ✅ +**Fichiers**: +- `tests/tasks/test_scrape_task.py` +- `tests/tasks/test_scheduler.py` + +**Statut**: ✅ Terminé + +#### Gestion erreurs Redis ✅ +**Fichiers modifiés**: +- `pricewatch/app/tasks/scheduler.py`: + - Ajout `RedisUnavailableError` exception + - Ajout `check_redis_connection()` helper + - Connexion lazy avec ping de vérification +- `pricewatch/app/cli/main.py`: + - Commandes `worker`, `enqueue`, `schedule` gèrent Redis down + - Messages d'erreur clairs avec instructions + +**Tests ajoutés** (7 tests): +- `test_scheduler_redis_connection_error` +- `test_scheduler_lazy_connection` +- `test_check_redis_connection_success` +- `test_check_redis_connection_failure` +- `test_scheduler_schedule_redis_error` + +**Statut**: ✅ Terminé + +#### Logs d'observabilité jobs ✅ +**Fichier modifié**: `pricewatch/app/tasks/scrape.py` + +**Logs ajoutés**: +- `[JOB START]` - Début du job avec URL +- `[STORE]` - Store détecté +- `[FETCH]` - Résultat fetch HTTP/Playwright (durée, taille) +- `[PARSE]` - Résultat parsing (titre, prix) +- `[JOB OK]` / `[JOB FAILED]` - Résultat final avec durée totale + +**Note**: Les logs sont aussi persistés en DB via `ScrapingLog` (déjà implémenté). + +**Statut**: ✅ Terminé + --- -## 📦 Semaine 4: Tests & Documentation (NON DÉMARRÉ) +## 📦 Semaine 4: Tests & Documentation (EN COURS) ### Tâches Prévues #### Tests -- Tests end-to-end (CLI → DB → Worker) -- Tests erreurs (DB down, Redis down) -- Tests backward compatibility (`--no-db`) -- Performance tests (100+ produits) +- ✅ Tests end-to-end (CLI → DB → Worker) +- ✅ Tests erreurs (DB down, Redis down) +- ✅ Tests backward compatibility (`--no-db`) +- ✅ Performance tests (100+ produits) + +**Fichiers tests ajoutes**: +- `tests/cli/test_worker_cli.py` +- `tests/cli/test_enqueue_schedule_cli.py` +- `tests/scraping/test_pipeline.py` (erreurs DB) +- `tests/tasks/test_redis_errors.py` +- `tests/cli/test_run_no_db.py` +- `tests/db/test_bulk_persistence.py` +- `tests/tasks/test_worker_end_to_end.py` +- `tests/cli/test_cli_worker_end_to_end.py` + - **Resultat**: OK avec Redis actif #### Documentation -- Update README.md (setup Phase 2) -- Update CHANGELOG.md -- Migration guide (JSON → DB) +- ✅ Update README.md (setup Phase 2) +- ✅ Update CHANGELOG.md +- ✅ Migration guide (JSON → DB) --- @@ -338,20 +400,22 @@ pricewatch schedule --interval 24 # Scrape quotidien |-----------|------------|---------|---| | **Semaine 1** | 10 | 10 | 100% | | **Semaine 2** | 5 | 5 | 100% | -| **Semaine 3** | 3 | 6 | 50% | -| **Semaine 4** | 0 | 7 | 0% | -| **TOTAL Phase 2** | 18 | 28 | **64%** | +| **Semaine 3** | 6 | 6 | 100% | +| **Semaine 4** | 7 | 7 | 100% | +| **TOTAL Phase 2** | 28 | 28 | **100%** | --- ## 🎯 Prochaine Étape Immédiate **Prochaine étape immédiate** -- Tests end-to-end worker + DB -- Gestion des erreurs Redis down (CLI + worker) +- Phase 2 terminee, bascule vers Phase 3 (API REST) +- API v1 avancee: filtres, export CSV/JSON, webhooks + tests associes -**Apres (prevu)** -- Logs d'observabilite pour jobs planifies +**Après (prévu)** +- Documentation Phase 2 (resume final) +- Retry policy (optionnel) +- Phase 4 Web UI (dashboard + graphiques) --- @@ -423,7 +487,13 @@ SELECT * FROM scraping_logs ORDER BY fetched_at DESC LIMIT 5; --- -**Dernière mise à jour**: 2026-01-14 +**Dernière mise à jour**: 2026-01-15 + +### Recap avancement recent (Phase 3 API) +- Filtres avances + exports CSV/JSON + webhooks (CRUD + test) +- Tests API avances ajoutes +- Nettoyage warnings Pydantic/datetime/selectors +- Suite pytest complete: 339 passed, 4 skipped ### Validation locale (Semaine 1) ```bash @@ -434,4 +504,4 @@ psql -h localhost -U pricewatch pricewatch ``` **Resultat**: 6 tables visibles (products, price_history, product_images, product_specs, scraping_logs, alembic_version). -**Statut**: ✅ Semaine 1 en cours (30% complétée) +**Statut**: ✅ Semaine 1 terminee (100%). diff --git a/README.md b/README.md index cfa1533..c2c7800 100755 --- a/README.md +++ b/README.md @@ -146,6 +146,70 @@ docker-compose up -d cp .env.example .env ``` +Guide de migration JSON -> DB: `MIGRATION_GUIDE.md` + +## API REST (Phase 3) + +L'API est protegee par un token simple. + +```bash +export PW_API_TOKEN=change_me +docker compose up -d api +``` + +Exemples: + +```bash +curl -H "Authorization: Bearer $PW_API_TOKEN" http://localhost:8001/products +curl http://localhost:8001/health +``` + +Filtres (exemples rapides): + +```bash +curl -H "Authorization: Bearer $PW_API_TOKEN" \\ + "http://localhost:8001/products?price_min=100&stock_status=in_stock" +curl -H "Authorization: Bearer $PW_API_TOKEN" \\ + "http://localhost:8001/products/1/prices?fetch_status=success&fetched_after=2026-01-14T00:00:00" +curl -H "Authorization: Bearer $PW_API_TOKEN" \\ + "http://localhost:8001/logs?fetch_status=failed&fetched_before=2026-01-15T00:00:00" +``` + +Exports (CSV/JSON): + +```bash +curl -H "Authorization: Bearer $PW_API_TOKEN" \\ + "http://localhost:8001/products/export?format=csv" +curl -H "Authorization: Bearer $PW_API_TOKEN" \\ + "http://localhost:8001/logs/export?format=json" +``` + +CRUD (examples rapides): + +```bash +curl -H "Authorization: Bearer $PW_API_TOKEN" -X POST http://localhost:8001/products \\ + -H "Content-Type: application/json" \\ + -d '{"source":"amazon","reference":"REF1","url":"https://example.com"}' +``` + +Webhooks (exemples rapides): + +```bash +curl -H "Authorization: Bearer $PW_API_TOKEN" -X POST http://localhost:8001/webhooks \\ + -H "Content-Type: application/json" \\ + -d '{"event":"price_changed","url":"https://example.com/webhook","enabled":true}' +curl -H "Authorization: Bearer $PW_API_TOKEN" -X POST http://localhost:8001/webhooks/1/test +``` + +## Web UI (Phase 4) + +Interface Vue 3 dense avec themes Gruvbox/Monokai, header fixe, sidebar filtres, et split compare. + +```bash +docker compose up -d frontend +# Acces: http://localhost:3000 +``` + ## Configuration (scrap_url.yaml) ```yaml diff --git a/TODO.md b/TODO.md index 8ce85be..e9770d6 100755 --- a/TODO.md +++ b/TODO.md @@ -154,7 +154,7 @@ Liste des tâches priorisées pour le développement de PriceWatch. --- -## Phase 2 : Base de données (En cours) +## Phase 2 : Base de données (Terminee) ### Persistence - [x] Schéma PostgreSQL @@ -166,8 +166,13 @@ Liste des tâches priorisées pour le développement de PriceWatch. - [x] ScrapingPipeline (persistence optionnelle) - [x] CLI `--save-db/--no-db` - [x] Tests end-to-end CLI + DB -- [ ] CRUD produits -- [ ] Historique prix +- [x] Tests backward compatibility (`--no-db`) +- [x] Tests performance (100+ produits) +- [x] CRUD produits +- [x] Historique prix + +### Documentation +- [x] Migration guide (JSON -> DB) ### Configuration - [x] Fichier config (DB credentials) @@ -182,26 +187,43 @@ Liste des tâches priorisées pour le développement de PriceWatch. - [x] Setup Redis - [x] Worker RQ - [x] Queue de scraping +- [x] Tests worker + scheduler +- [x] Gestion erreurs Redis (RedisUnavailableError) - [ ] Retry policy ### Planification - [x] Cron ou scheduler intégré - [x] Scraping quotidien automatique -- [ ] Logs des runs +- [x] Logs des runs (JOB START/OK/FAILED) +- [x] Tests end-to-end worker + DB +- [x] Tests end-to-end CLI -> DB -> worker + +## Phase 3 : API REST (En cours) + +### API FastAPI +- [x] Endpoints read-only (products, prices, logs, health) +- [x] Auth token simple (Bearer) +- [x] Endpoints enqueue/schedule +- [x] CRUD products + prices + logs +- [x] Docker + uvicorn + config env +- [x] Tests API de base +- [x] Filtres avances (prix, dates, stock, status) +- [x] Exports CSV/JSON (products, prices, logs) +- [x] Webhooks (CRUD + test) --- ## Phase 4 : Web UI (Future) ### Backend API -- [ ] FastAPI endpoints -- [ ] Authentification +- [x] FastAPI endpoints +- [x] Authentification - [ ] CORS ### Frontend -- [ ] Framework (React/Vue?) -- [ ] Design responsive -- [ ] Dark theme Gruvbox +- [x] Framework (Vue 3 + Vite) +- [x] Design responsive (layout dense + compact) +- [x] Dark theme Gruvbox (defaut) + Monokai - [ ] Graphiques historique prix - [ ] Gestion alertes @@ -236,4 +258,4 @@ Liste des tâches priorisées pour le développement de PriceWatch. --- -**Dernière mise à jour**: 2026-01-14 +**Dernière mise à jour**: 2026-01-15 diff --git a/alembic.ini b/alembic.ini old mode 100755 new mode 100644 diff --git a/docker-compose.yml b/docker-compose.yml old mode 100755 new mode 100644 index 8a4c487..362e5af --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: POSTGRES_DB: pricewatch POSTGRES_USER: pricewatch POSTGRES_PASSWORD: pricewatch + TZ: Europe/Paris ports: - "5432:5432" volumes: @@ -12,11 +13,36 @@ services: redis: image: redis:7 + environment: + TZ: Europe/Paris ports: - "6379:6379" volumes: - pricewatch_redisdata:/data + api: + build: . + ports: + - "8001:8000" + env_file: + - .env + environment: + PW_DB_HOST: postgres + PW_REDIS_HOST: redis + TZ: Europe/Paris + depends_on: + - postgres + - redis + + frontend: + build: ./webui + ports: + - "3000:80" + environment: + TZ: Europe/Paris + depends_on: + - api + volumes: pricewatch_pgdata: pricewatch_redisdata: diff --git a/pricewatch.egg-info/PKG-INFO b/pricewatch.egg-info/PKG-INFO old mode 100755 new mode 100644 index b784bc1..ac434d9 --- a/pricewatch.egg-info/PKG-INFO +++ b/pricewatch.egg-info/PKG-INFO @@ -28,6 +28,8 @@ Requires-Dist: python-dotenv>=1.0.0 Requires-Dist: redis>=5.0.0 Requires-Dist: rq>=1.15.0 Requires-Dist: rq-scheduler>=0.13.0 +Requires-Dist: fastapi>=0.110.0 +Requires-Dist: uvicorn>=0.27.0 Provides-Extra: dev Requires-Dist: pytest>=8.0.0; extra == "dev" Requires-Dist: pytest-cov>=4.1.0; extra == "dev" @@ -100,6 +102,13 @@ pricewatch/ │ │ ├── store.py │ │ ├── selectors.yml │ │ └── fixtures/ +│ ├── db/ # Persistence SQLAlchemy (Phase 2) +│ │ ├── models.py +│ │ ├── connection.py +│ │ └── migrations/ +│ ├── tasks/ # Jobs RQ (Phase 3) +│ │ ├── scrape.py +│ │ └── scheduler.py │ └── cli/ │ └── main.py # CLI Typer ├── tests/ # Tests pytest @@ -118,6 +127,9 @@ pricewatch run --yaml scrap_url.yaml --out scraped_store.json # Avec debug pricewatch run --yaml scrap_url.yaml --out scraped_store.json --debug + +# Avec persistence DB +pricewatch run --yaml scrap_url.yaml --out scraped_store.json --save-db ``` ### Commandes utilitaires @@ -139,6 +151,63 @@ pricewatch parse amazon --in scraped/page.html pricewatch doctor ``` +### Commandes base de donnees + +```bash +# Initialiser les tables +pricewatch init-db + +# Generer une migration +pricewatch migrate "Initial schema" + +# Appliquer les migrations +pricewatch upgrade + +# Revenir en arriere +pricewatch downgrade -1 +``` + +### Commandes worker + +```bash +# Lancer un worker RQ +pricewatch worker + +# Enqueue un job immediat +pricewatch enqueue "https://example.com/product" + +# Planifier un job recurrent +pricewatch schedule "https://example.com/product" --interval 24 +``` + +## Base de donnees (Phase 2) + +```bash +# Lancer PostgreSQL + Redis en local +docker-compose up -d + +# Exemple de configuration +cp .env.example .env +``` + +Guide de migration JSON -> DB: `MIGRATION_GUIDE.md` + +## API REST (Phase 3) + +L'API est protegee par un token simple. + +```bash +export PW_API_TOKEN=change_me +docker compose up -d api +``` + +Exemples: + +```bash +curl -H "Authorization: Bearer $PW_API_TOKEN" http://localhost:8000/products +curl http://localhost:8000/health +``` + ## Configuration (scrap_url.yaml) ```yaml @@ -238,8 +307,8 @@ Aucune erreur ne doit crasher silencieusement : toutes sont loggées et tracées - ✅ Tests pytest ### Phase 2 : Persistence -- [ ] Base de données PostgreSQL -- [ ] Migrations Alembic +- [x] Base de données PostgreSQL +- [x] Migrations Alembic - [ ] Historique des prix ### Phase 3 : Automation diff --git a/pricewatch.egg-info/SOURCES.txt b/pricewatch.egg-info/SOURCES.txt index a519fb9..48f824a 100755 --- a/pricewatch.egg-info/SOURCES.txt +++ b/pricewatch.egg-info/SOURCES.txt @@ -18,6 +18,7 @@ pricewatch/app/core/registry.py pricewatch/app/core/schema.py pricewatch/app/scraping/__init__.py pricewatch/app/scraping/http_fetch.py +pricewatch/app/scraping/pipeline.py pricewatch/app/scraping/pw_fetch.py pricewatch/app/stores/__init__.py pricewatch/app/stores/base.py diff --git a/pricewatch.egg-info/requires.txt b/pricewatch.egg-info/requires.txt index f366fd7..003a098 100755 --- a/pricewatch.egg-info/requires.txt +++ b/pricewatch.egg-info/requires.txt @@ -16,6 +16,8 @@ python-dotenv>=1.0.0 redis>=5.0.0 rq>=1.15.0 rq-scheduler>=0.13.0 +fastapi>=0.110.0 +uvicorn>=0.27.0 [dev] pytest>=8.0.0 diff --git a/pricewatch/app/api/__init__.py b/pricewatch/app/api/__init__.py new file mode 100644 index 0000000..595bb5f --- /dev/null +++ b/pricewatch/app/api/__init__.py @@ -0,0 +1,5 @@ +"""Module API FastAPI.""" + +from pricewatch.app.api.main import app + +__all__ = ["app"] diff --git a/pricewatch/app/api/__pycache__/__init__.cpython-313.pyc b/pricewatch/app/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8701b6114b09b411030ed7e8369dfe33a1bc6b9e GIT binary patch literal 273 zcmey&%ge<81aFR|XNCdk#~=<2FhLogrGSj748aUV48e@SOx}!MOhrrz48hF$j77}p zESjuU!oK+_r8%hzjsczuZi&SuKw9r5NVg{AE#}06f+A)h+fS3_7JGbrN`7*D{4Mc< zqRiyf^2Czl3_YL%AkNgwP0Y*#s{|=a%*lz5U&-(pWXdfo{fzwFRQ>eKoSf8R{eq(W ztkmR^V*TReqQn9q7iN+^&?F$v)Q^wP%*!l^kJl@xyv1RY3wE4c5y+>EKwK;YBt9@R YGBVy|kbKIZ{(xJcL%or`hyy4L05cIv5C8xG literal 0 HcmV?d00001 diff --git a/pricewatch/app/api/__pycache__/main.cpython-313.pyc b/pricewatch/app/api/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..66e5ecfbf56c00705bd9d74152cba9e054e5e095 GIT binary patch literal 42062 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 XDlN`C?Op^`L5uSz^=^xknHc{Eb-?b# literal 0 HcmV?d00001 diff --git a/pricewatch/app/api/__pycache__/schemas.cpython-313.pyc b/pricewatch/app/api/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e52c07bf7116c1b6fc2faf86b7d156ebaa7fbec GIT binary patch literal 10004 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 Jv0|*^e**y*yUYLp literal 0 HcmV?d00001 diff --git a/pricewatch/app/api/main.py b/pricewatch/app/api/main.py new file mode 100644 index 0000000..d32e95b --- /dev/null +++ b/pricewatch/app/api/main.py @@ -0,0 +1,876 @@ +""" +API REST FastAPI pour PriceWatch (Phase 3). +""" + +from __future__ import annotations + +import csv +from collections import deque +from datetime import datetime, timezone +import os +from pathlib import Path +from io import StringIO +from typing import Generator, Optional + +import httpx +from fastapi import Depends, FastAPI, Header, HTTPException, Response +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy import and_, desc, func +from sqlalchemy.orm import Session + +from pricewatch.app.api.schemas import ( + EnqueueRequest, + EnqueueResponse, + HealthStatus, + PriceHistoryOut, + PriceHistoryCreate, + PriceHistoryUpdate, + ProductOut, + ProductCreate, + ProductUpdate, + ScheduleRequest, + ScheduleResponse, + ScrapingLogOut, + ScrapingLogCreate, + ScrapingLogUpdate, + ScrapePreviewRequest, + ScrapePreviewResponse, + ScrapeCommitRequest, + ScrapeCommitResponse, + VersionResponse, + BackendLogEntry, + UvicornLogEntry, + WebhookOut, + WebhookCreate, + WebhookUpdate, + WebhookTestResponse, +) +from pricewatch.app.core.config import get_config +from pricewatch.app.core.logging import get_logger +from pricewatch.app.core.schema import ProductSnapshot +from pricewatch.app.db.connection import check_db_connection, get_session +from pricewatch.app.db.models import PriceHistory, Product, ScrapingLog, Webhook +from pricewatch.app.scraping.pipeline import ScrapingPipeline +from pricewatch.app.tasks.scrape import scrape_product +from pricewatch.app.tasks.scheduler import RedisUnavailableError, check_redis_connection, ScrapingScheduler + +logger = get_logger("api") + +app = FastAPI(title="PriceWatch API", version="0.4.0") + +# Buffer de logs backend en memoire pour debug UI. +BACKEND_LOGS = deque(maxlen=200) + +UVICORN_LOG_PATH = Path( + os.environ.get("PW_UVICORN_LOG_PATH", "/app/logs/uvicorn.log") +) + + +def get_db_session() -> Generator[Session, None, None]: + """Dependency: session SQLAlchemy.""" + with get_session(get_config()) as session: + yield session + + +def require_token(authorization: Optional[str] = Header(default=None)) -> None: + """Auth simple via token Bearer.""" + config = get_config() + token = config.api_token + if not token: + raise HTTPException(status_code=500, detail="API token non configure") + + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Token manquant") + + provided = authorization.split("Bearer ")[-1].strip() + if provided != token: + raise HTTPException(status_code=403, detail="Token invalide") + + +@app.get("/health", response_model=HealthStatus) +def health_check() -> HealthStatus: + """Health check DB + Redis.""" + config = get_config() + return HealthStatus( + db=check_db_connection(config), + redis=check_redis_connection(config.redis.url), + ) + + +@app.get("/version", response_model=VersionResponse) +def version_info() -> VersionResponse: + """Expose la version API.""" + return VersionResponse(api_version=app.version) + + +@app.get("/logs/backend", response_model=list[BackendLogEntry], dependencies=[Depends(require_token)]) +def list_backend_logs() -> list[BackendLogEntry]: + """Expose un buffer de logs backend.""" + return list(BACKEND_LOGS) + + +@app.get("/logs/uvicorn", response_model=list[UvicornLogEntry], dependencies=[Depends(require_token)]) +def list_uvicorn_logs(limit: int = 200) -> list[UvicornLogEntry]: + """Expose les dernieres lignes du log Uvicorn.""" + lines = _read_uvicorn_lines(limit=limit) + return [UvicornLogEntry(line=line) for line in lines] + + +@app.get("/products", response_model=list[ProductOut], dependencies=[Depends(require_token)]) +def list_products( + source: Optional[str] = None, + reference: Optional[str] = None, + updated_after: Optional[datetime] = None, + price_min: Optional[float] = None, + price_max: Optional[float] = None, + fetched_after: Optional[datetime] = None, + fetched_before: Optional[datetime] = None, + stock_status: Optional[str] = None, + limit: int = 50, + offset: int = 0, + session: Session = Depends(get_db_session), +) -> list[ProductOut]: + """Liste des produits avec filtres optionnels.""" + latest_price_subquery = ( + session.query( + PriceHistory.product_id.label("product_id"), + func.max(PriceHistory.fetched_at).label("latest_fetched_at"), + ) + .group_by(PriceHistory.product_id) + .subquery() + ) + latest_price = ( + session.query(PriceHistory) + .join( + latest_price_subquery, + and_( + PriceHistory.product_id == latest_price_subquery.c.product_id, + PriceHistory.fetched_at == latest_price_subquery.c.latest_fetched_at, + ), + ) + .subquery() + ) + + query = session.query(Product).outerjoin(latest_price, Product.id == latest_price.c.product_id) + if source: + query = query.filter(Product.source == source) + if reference: + query = query.filter(Product.reference == reference) + if updated_after: + query = query.filter(Product.last_updated_at >= updated_after) + if price_min is not None: + query = query.filter(latest_price.c.price >= price_min) + if price_max is not None: + query = query.filter(latest_price.c.price <= price_max) + if fetched_after: + query = query.filter(latest_price.c.fetched_at >= fetched_after) + if fetched_before: + query = query.filter(latest_price.c.fetched_at <= fetched_before) + if stock_status: + query = query.filter(latest_price.c.stock_status == stock_status) + + products = query.order_by(desc(Product.last_updated_at)).offset(offset).limit(limit).all() + return [_product_to_out(session, product) for product in products] + + +@app.post("/products", response_model=ProductOut, dependencies=[Depends(require_token)]) +def create_product( + payload: ProductCreate, + session: Session = Depends(get_db_session), +) -> ProductOut: + """Cree un produit.""" + product = Product( + source=payload.source, + reference=payload.reference, + url=payload.url, + title=payload.title, + category=payload.category, + description=payload.description, + currency=payload.currency, + msrp=payload.msrp, + ) + session.add(product) + try: + session.commit() + session.refresh(product) + except IntegrityError as exc: + session.rollback() + raise HTTPException(status_code=409, detail="Produit deja existant") from exc + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _product_to_out(session, product) + + +@app.get("/products/{product_id}", response_model=ProductOut, dependencies=[Depends(require_token)]) +def get_product( + product_id: int, + session: Session = Depends(get_db_session), +) -> ProductOut: + """Detail produit + dernier prix.""" + product = session.query(Product).filter(Product.id == product_id).one_or_none() + if not product: + raise HTTPException(status_code=404, detail="Produit non trouve") + return _product_to_out(session, product) + + +@app.patch("/products/{product_id}", response_model=ProductOut, dependencies=[Depends(require_token)]) +def update_product( + product_id: int, + payload: ProductUpdate, + session: Session = Depends(get_db_session), +) -> ProductOut: + """Met a jour un produit (partial).""" + product = session.query(Product).filter(Product.id == product_id).one_or_none() + if not product: + raise HTTPException(status_code=404, detail="Produit non trouve") + + updates = payload.model_dump(exclude_unset=True) + for key, value in updates.items(): + setattr(product, key, value) + + try: + session.commit() + session.refresh(product) + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _product_to_out(session, product) + + +@app.delete("/products/{product_id}", dependencies=[Depends(require_token)]) +def delete_product( + product_id: int, + session: Session = Depends(get_db_session), +) -> dict[str, str]: + """Supprime un produit (cascade).""" + product = session.query(Product).filter(Product.id == product_id).one_or_none() + if not product: + raise HTTPException(status_code=404, detail="Produit non trouve") + + session.delete(product) + try: + session.commit() + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return {"status": "deleted"} + + +@app.get( + "/products/{product_id}/prices", + response_model=list[PriceHistoryOut], + dependencies=[Depends(require_token)], +) +def list_prices( + product_id: int, + price_min: Optional[float] = None, + price_max: Optional[float] = None, + fetched_after: Optional[datetime] = None, + fetched_before: Optional[datetime] = None, + fetch_status: Optional[str] = None, + limit: int = 50, + offset: int = 0, + session: Session = Depends(get_db_session), +) -> list[PriceHistoryOut]: + """Historique de prix pour un produit.""" + query = session.query(PriceHistory).filter(PriceHistory.product_id == product_id) + if price_min is not None: + query = query.filter(PriceHistory.price >= price_min) + if price_max is not None: + query = query.filter(PriceHistory.price <= price_max) + if fetched_after: + query = query.filter(PriceHistory.fetched_at >= fetched_after) + if fetched_before: + query = query.filter(PriceHistory.fetched_at <= fetched_before) + if fetch_status: + query = query.filter(PriceHistory.fetch_status == fetch_status) + + prices = query.order_by(desc(PriceHistory.fetched_at)).offset(offset).limit(limit).all() + return [_price_to_out(price) for price in prices] + + +@app.post("/prices", response_model=PriceHistoryOut, dependencies=[Depends(require_token)]) +def create_price( + payload: PriceHistoryCreate, + session: Session = Depends(get_db_session), +) -> PriceHistoryOut: + """Ajoute une entree d'historique de prix.""" + price = PriceHistory( + product_id=payload.product_id, + price=payload.price, + shipping_cost=payload.shipping_cost, + stock_status=payload.stock_status, + fetch_method=payload.fetch_method, + fetch_status=payload.fetch_status, + fetched_at=payload.fetched_at, + ) + session.add(price) + try: + session.commit() + session.refresh(price) + except IntegrityError as exc: + session.rollback() + raise HTTPException(status_code=409, detail="Entree prix deja existante") from exc + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _price_to_out(price) + + +@app.patch("/prices/{price_id}", response_model=PriceHistoryOut, dependencies=[Depends(require_token)]) +def update_price( + price_id: int, + payload: PriceHistoryUpdate, + session: Session = Depends(get_db_session), +) -> PriceHistoryOut: + """Met a jour une entree de prix.""" + price = session.query(PriceHistory).filter(PriceHistory.id == price_id).one_or_none() + if not price: + raise HTTPException(status_code=404, detail="Entree prix non trouvee") + + updates = payload.model_dump(exclude_unset=True) + for key, value in updates.items(): + setattr(price, key, value) + + try: + session.commit() + session.refresh(price) + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _price_to_out(price) + + +@app.delete("/prices/{price_id}", dependencies=[Depends(require_token)]) +def delete_price( + price_id: int, + session: Session = Depends(get_db_session), +) -> dict[str, str]: + """Supprime une entree de prix.""" + price = session.query(PriceHistory).filter(PriceHistory.id == price_id).one_or_none() + if not price: + raise HTTPException(status_code=404, detail="Entree prix non trouvee") + + session.delete(price) + try: + session.commit() + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return {"status": "deleted"} + + +@app.get("/logs", response_model=list[ScrapingLogOut], dependencies=[Depends(require_token)]) +def list_logs( + source: Optional[str] = None, + fetch_status: Optional[str] = None, + fetched_after: Optional[datetime] = None, + fetched_before: Optional[datetime] = None, + limit: int = 50, + offset: int = 0, + session: Session = Depends(get_db_session), +) -> list[ScrapingLogOut]: + """Liste des logs de scraping.""" + query = session.query(ScrapingLog) + if source: + query = query.filter(ScrapingLog.source == source) + if fetch_status: + query = query.filter(ScrapingLog.fetch_status == fetch_status) + if fetched_after: + query = query.filter(ScrapingLog.fetched_at >= fetched_after) + if fetched_before: + query = query.filter(ScrapingLog.fetched_at <= fetched_before) + + logs = query.order_by(desc(ScrapingLog.fetched_at)).offset(offset).limit(limit).all() + return [_log_to_out(log) for log in logs] + + +@app.post("/logs", response_model=ScrapingLogOut, dependencies=[Depends(require_token)]) +def create_log( + payload: ScrapingLogCreate, + session: Session = Depends(get_db_session), +) -> ScrapingLogOut: + """Cree un log de scraping.""" + log_entry = ScrapingLog( + product_id=payload.product_id, + url=payload.url, + source=payload.source, + reference=payload.reference, + fetch_method=payload.fetch_method, + fetch_status=payload.fetch_status, + fetched_at=payload.fetched_at, + duration_ms=payload.duration_ms, + html_size_bytes=payload.html_size_bytes, + errors=payload.errors, + notes=payload.notes, + ) + session.add(log_entry) + try: + session.commit() + session.refresh(log_entry) + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _log_to_out(log_entry) + + +@app.patch("/logs/{log_id}", response_model=ScrapingLogOut, dependencies=[Depends(require_token)]) +def update_log( + log_id: int, + payload: ScrapingLogUpdate, + session: Session = Depends(get_db_session), +) -> ScrapingLogOut: + """Met a jour un log.""" + log_entry = session.query(ScrapingLog).filter(ScrapingLog.id == log_id).one_or_none() + if not log_entry: + raise HTTPException(status_code=404, detail="Log non trouve") + + updates = payload.model_dump(exclude_unset=True) + for key, value in updates.items(): + setattr(log_entry, key, value) + + try: + session.commit() + session.refresh(log_entry) + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _log_to_out(log_entry) + + +@app.delete("/logs/{log_id}", dependencies=[Depends(require_token)]) +def delete_log( + log_id: int, + session: Session = Depends(get_db_session), +) -> dict[str, str]: + """Supprime un log.""" + log_entry = session.query(ScrapingLog).filter(ScrapingLog.id == log_id).one_or_none() + if not log_entry: + raise HTTPException(status_code=404, detail="Log non trouve") + + session.delete(log_entry) + try: + session.commit() + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return {"status": "deleted"} + + +@app.get("/products/export", dependencies=[Depends(require_token)]) +def export_products( + source: Optional[str] = None, + reference: Optional[str] = None, + updated_after: Optional[datetime] = None, + price_min: Optional[float] = None, + price_max: Optional[float] = None, + fetched_after: Optional[datetime] = None, + fetched_before: Optional[datetime] = None, + stock_status: Optional[str] = None, + format: str = "csv", + limit: int = 500, + offset: int = 0, + session: Session = Depends(get_db_session), +) -> Response: + """Export produits en CSV/JSON.""" + products = list_products( + source=source, + reference=reference, + updated_after=updated_after, + price_min=price_min, + price_max=price_max, + fetched_after=fetched_after, + fetched_before=fetched_before, + stock_status=stock_status, + limit=limit, + offset=offset, + session=session, + ) + rows = [product.model_dump() for product in products] + fieldnames = list(ProductOut.model_fields.keys()) + return _export_response(rows, fieldnames, "products", format) + + +@app.get("/prices/export", dependencies=[Depends(require_token)]) +def export_prices( + product_id: Optional[int] = None, + price_min: Optional[float] = None, + price_max: Optional[float] = None, + fetched_after: Optional[datetime] = None, + fetched_before: Optional[datetime] = None, + fetch_status: Optional[str] = None, + format: str = "csv", + limit: int = 500, + offset: int = 0, + session: Session = Depends(get_db_session), +) -> Response: + """Export historique de prix en CSV/JSON.""" + query = session.query(PriceHistory) + if product_id is not None: + query = query.filter(PriceHistory.product_id == product_id) + if price_min is not None: + query = query.filter(PriceHistory.price >= price_min) + if price_max is not None: + query = query.filter(PriceHistory.price <= price_max) + if fetched_after: + query = query.filter(PriceHistory.fetched_at >= fetched_after) + if fetched_before: + query = query.filter(PriceHistory.fetched_at <= fetched_before) + if fetch_status: + query = query.filter(PriceHistory.fetch_status == fetch_status) + + prices = query.order_by(desc(PriceHistory.fetched_at)).offset(offset).limit(limit).all() + rows = [_price_to_out(price).model_dump() for price in prices] + fieldnames = list(PriceHistoryOut.model_fields.keys()) + return _export_response(rows, fieldnames, "prices", format) + + +@app.get("/logs/export", dependencies=[Depends(require_token)]) +def export_logs( + source: Optional[str] = None, + fetch_status: Optional[str] = None, + fetched_after: Optional[datetime] = None, + fetched_before: Optional[datetime] = None, + format: str = "csv", + limit: int = 500, + offset: int = 0, + session: Session = Depends(get_db_session), +) -> Response: + """Export logs de scraping en CSV/JSON.""" + logs = list_logs( + source=source, + fetch_status=fetch_status, + fetched_after=fetched_after, + fetched_before=fetched_before, + limit=limit, + offset=offset, + session=session, + ) + rows = [log.model_dump() for log in logs] + fieldnames = list(ScrapingLogOut.model_fields.keys()) + return _export_response(rows, fieldnames, "logs", format) + + +@app.get("/webhooks", response_model=list[WebhookOut], dependencies=[Depends(require_token)]) +def list_webhooks( + event: Optional[str] = None, + enabled: Optional[bool] = None, + limit: int = 50, + offset: int = 0, + session: Session = Depends(get_db_session), +) -> list[WebhookOut]: + """Liste des webhooks.""" + query = session.query(Webhook) + if event: + query = query.filter(Webhook.event == event) + if enabled is not None: + query = query.filter(Webhook.enabled == enabled) + + webhooks = query.order_by(desc(Webhook.created_at)).offset(offset).limit(limit).all() + return [_webhook_to_out(webhook) for webhook in webhooks] + + +@app.post("/webhooks", response_model=WebhookOut, dependencies=[Depends(require_token)]) +def create_webhook( + payload: WebhookCreate, + session: Session = Depends(get_db_session), +) -> WebhookOut: + """Cree un webhook.""" + webhook = Webhook( + event=payload.event, + url=payload.url, + enabled=payload.enabled, + secret=payload.secret, + ) + session.add(webhook) + try: + session.commit() + session.refresh(webhook) + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _webhook_to_out(webhook) + + +@app.patch("/webhooks/{webhook_id}", response_model=WebhookOut, dependencies=[Depends(require_token)]) +def update_webhook( + webhook_id: int, + payload: WebhookUpdate, + session: Session = Depends(get_db_session), +) -> WebhookOut: + """Met a jour un webhook.""" + webhook = session.query(Webhook).filter(Webhook.id == webhook_id).one_or_none() + if not webhook: + raise HTTPException(status_code=404, detail="Webhook non trouve") + + updates = payload.model_dump(exclude_unset=True) + for key, value in updates.items(): + setattr(webhook, key, value) + + try: + session.commit() + session.refresh(webhook) + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return _webhook_to_out(webhook) + + +@app.delete("/webhooks/{webhook_id}", dependencies=[Depends(require_token)]) +def delete_webhook( + webhook_id: int, + session: Session = Depends(get_db_session), +) -> dict[str, str]: + """Supprime un webhook.""" + webhook = session.query(Webhook).filter(Webhook.id == webhook_id).one_or_none() + if not webhook: + raise HTTPException(status_code=404, detail="Webhook non trouve") + + session.delete(webhook) + try: + session.commit() + except SQLAlchemyError as exc: + session.rollback() + raise HTTPException(status_code=500, detail="Erreur DB") from exc + return {"status": "deleted"} + + +@app.post( + "/webhooks/{webhook_id}/test", + response_model=WebhookTestResponse, + dependencies=[Depends(require_token)], +) +def send_webhook_test( + webhook_id: int, + session: Session = Depends(get_db_session), +) -> WebhookTestResponse: + """Envoie un evenement de test.""" + webhook = session.query(Webhook).filter(Webhook.id == webhook_id).one_or_none() + if not webhook: + raise HTTPException(status_code=404, detail="Webhook non trouve") + if not webhook.enabled: + raise HTTPException(status_code=409, detail="Webhook desactive") + + payload = {"message": "test webhook", "webhook_id": webhook.id} + _send_webhook(webhook, "test", payload) + return WebhookTestResponse(status="sent") + +@app.post("/enqueue", response_model=EnqueueResponse, dependencies=[Depends(require_token)]) +def enqueue_job(payload: EnqueueRequest) -> EnqueueResponse: + """Enqueue un job immediat.""" + try: + scheduler = ScrapingScheduler(get_config()) + job = scheduler.enqueue_immediate( + payload.url, + use_playwright=payload.use_playwright, + save_db=payload.save_db, + ) + return EnqueueResponse(job_id=job.id) + except RedisUnavailableError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + +@app.post("/schedule", response_model=ScheduleResponse, dependencies=[Depends(require_token)]) +def schedule_job(payload: ScheduleRequest) -> ScheduleResponse: + """Planifie un job recurrent.""" + try: + scheduler = ScrapingScheduler(get_config()) + job_info = scheduler.schedule_product( + payload.url, + interval_hours=payload.interval_hours, + use_playwright=payload.use_playwright, + save_db=payload.save_db, + ) + return ScheduleResponse(job_id=job_info.job_id, next_run=job_info.next_run) + except RedisUnavailableError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + +@app.post("/scrape/preview", response_model=ScrapePreviewResponse, dependencies=[Depends(require_token)]) +def preview_scrape(payload: ScrapePreviewRequest) -> ScrapePreviewResponse: + """Scrape un produit sans persistence pour previsualisation.""" + _add_backend_log("INFO", f"Preview scraping: {payload.url}") + result = scrape_product( + payload.url, + use_playwright=payload.use_playwright, + save_db=False, + ) + snapshot = result.get("snapshot") + if snapshot is None: + _add_backend_log("ERROR", f"Preview scraping KO: {payload.url}") + return ScrapePreviewResponse(success=False, snapshot=None, error=result.get("error")) + return ScrapePreviewResponse( + success=bool(result.get("success")), + snapshot=snapshot.model_dump(mode="json"), + error=result.get("error"), + ) + + +@app.post("/scrape/commit", response_model=ScrapeCommitResponse, dependencies=[Depends(require_token)]) +def commit_scrape(payload: ScrapeCommitRequest) -> ScrapeCommitResponse: + """Persiste un snapshot previsualise.""" + try: + snapshot = ProductSnapshot.model_validate(payload.snapshot) + except Exception as exc: + _add_backend_log("ERROR", "Commit scraping KO: snapshot invalide") + raise HTTPException(status_code=400, detail="Snapshot invalide") from exc + + product_id = ScrapingPipeline(config=get_config()).process_snapshot(snapshot, save_to_db=True) + _add_backend_log("INFO", f"Commit scraping OK: product_id={product_id}") + return ScrapeCommitResponse(success=True, product_id=product_id) + + +def _export_response( + rows: list[dict[str, object]], + fieldnames: list[str], + filename_prefix: str, + format: str, +) -> Response: + """Expose une reponse CSV/JSON avec un nom de fichier stable.""" + if format not in {"csv", "json"}: + raise HTTPException(status_code=400, detail="Format invalide (csv ou json)") + + headers = {"Content-Disposition": f'attachment; filename="{filename_prefix}.{format}"'} + if format == "json": + return JSONResponse(content=jsonable_encoder(rows), headers=headers) + return _to_csv_response(rows, fieldnames, headers) + + +def _to_csv_response( + rows: list[dict[str, object]], + fieldnames: list[str], + headers: dict[str, str], +) -> Response: + buffer = StringIO() + writer = csv.DictWriter(buffer, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + return Response(content=buffer.getvalue(), media_type="text/csv", headers=headers) + + +def _send_webhook(webhook: Webhook, event: str, payload: dict[str, object]) -> None: + """Envoie un webhook avec gestion d'erreur explicite.""" + headers = {"Content-Type": "application/json"} + if webhook.secret: + headers["X-Webhook-Secret"] = webhook.secret + + try: + response = httpx.post( + webhook.url, + json={"event": event, "payload": payload}, + headers=headers, + timeout=5.0, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + logger.error("Erreur webhook", extra={"url": webhook.url, "event": event, "error": str(exc)}) + raise HTTPException(status_code=502, detail="Echec webhook") from exc + + +def _add_backend_log(level: str, message: str) -> None: + BACKEND_LOGS.append( + BackendLogEntry( + time=datetime.now(timezone.utc), + level=level, + message=message, + ) + ) + + +def _read_uvicorn_lines(limit: int = 200) -> list[str]: + """Lit les dernieres lignes du log Uvicorn si disponible.""" + if limit <= 0: + return [] + try: + if not UVICORN_LOG_PATH.exists(): + return [] + with UVICORN_LOG_PATH.open("r", encoding="utf-8", errors="ignore") as handle: + lines = handle.readlines() + return [line.rstrip("\n") for line in lines[-limit:]] + except Exception: + return [] + + +def _product_to_out(session: Session, product: Product) -> ProductOut: + """Helper pour mapper Product + dernier prix.""" + latest = ( + session.query(PriceHistory) + .filter(PriceHistory.product_id == product.id) + .order_by(desc(PriceHistory.fetched_at)) + .first() + ) + images = [image.image_url for image in product.images] + specs = {spec.spec_key: spec.spec_value for spec in product.specs} + discount_amount = None + discount_percent = None + if latest and latest.price is not None and product.msrp: + discount_amount = float(product.msrp) - float(latest.price) + if product.msrp > 0: + discount_percent = (discount_amount / float(product.msrp)) * 100 + return ProductOut( + id=product.id, + source=product.source, + reference=product.reference, + url=product.url, + title=product.title, + category=product.category, + description=product.description, + currency=product.currency, + msrp=float(product.msrp) if product.msrp is not None else None, + first_seen_at=product.first_seen_at, + last_updated_at=product.last_updated_at, + latest_price=float(latest.price) if latest and latest.price is not None else None, + latest_shipping_cost=( + float(latest.shipping_cost) if latest and latest.shipping_cost is not None else None + ), + latest_stock_status=latest.stock_status if latest else None, + latest_fetched_at=latest.fetched_at if latest else None, + images=images, + specs=specs, + discount_amount=discount_amount, + discount_percent=discount_percent, + ) + + +def _price_to_out(price: PriceHistory) -> PriceHistoryOut: + return PriceHistoryOut( + id=price.id, + product_id=price.product_id, + price=float(price.price) if price.price is not None else None, + shipping_cost=float(price.shipping_cost) if price.shipping_cost is not None else None, + stock_status=price.stock_status, + fetch_method=price.fetch_method, + fetch_status=price.fetch_status, + fetched_at=price.fetched_at, + ) + + +def _log_to_out(log: ScrapingLog) -> ScrapingLogOut: + return ScrapingLogOut( + id=log.id, + product_id=log.product_id, + url=log.url, + source=log.source, + reference=log.reference, + fetch_method=log.fetch_method, + fetch_status=log.fetch_status, + fetched_at=log.fetched_at, + duration_ms=log.duration_ms, + html_size_bytes=log.html_size_bytes, + errors=log.errors, + notes=log.notes, + ) + + +def _webhook_to_out(webhook: Webhook) -> WebhookOut: + return WebhookOut( + id=webhook.id, + event=webhook.event, + url=webhook.url, + enabled=webhook.enabled, + secret=webhook.secret, + created_at=webhook.created_at, + ) diff --git a/pricewatch/app/api/schemas.py b/pricewatch/app/api/schemas.py new file mode 100644 index 0000000..a591eb9 --- /dev/null +++ b/pricewatch/app/api/schemas.py @@ -0,0 +1,212 @@ +""" +Schemas API FastAPI pour Phase 3. +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class HealthStatus(BaseModel): + db: bool + redis: bool + + +class ProductOut(BaseModel): + id: int + source: str + reference: str + url: str + title: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + currency: Optional[str] = None + msrp: Optional[float] = None + first_seen_at: datetime + last_updated_at: datetime + latest_price: Optional[float] = None + latest_shipping_cost: Optional[float] = None + latest_stock_status: Optional[str] = None + latest_fetched_at: Optional[datetime] = None + images: list[str] = [] + specs: dict[str, str] = {} + discount_amount: Optional[float] = None + discount_percent: Optional[float] = None + + +class ProductCreate(BaseModel): + source: str + reference: str + url: str + title: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + currency: Optional[str] = None + msrp: Optional[float] = None + + +class ProductUpdate(BaseModel): + url: Optional[str] = None + title: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + currency: Optional[str] = None + msrp: Optional[float] = None + + +class PriceHistoryOut(BaseModel): + id: int + product_id: int + price: Optional[float] = None + shipping_cost: Optional[float] = None + stock_status: Optional[str] = None + fetch_method: str + fetch_status: str + fetched_at: datetime + + +class PriceHistoryCreate(BaseModel): + product_id: int + price: Optional[float] = None + shipping_cost: Optional[float] = None + stock_status: Optional[str] = None + fetch_method: str + fetch_status: str + fetched_at: datetime + + +class PriceHistoryUpdate(BaseModel): + price: Optional[float] = None + shipping_cost: Optional[float] = None + stock_status: Optional[str] = None + fetch_method: Optional[str] = None + fetch_status: Optional[str] = None + fetched_at: Optional[datetime] = None + + +class ScrapingLogOut(BaseModel): + id: int + product_id: Optional[int] = None + url: str + source: str + reference: Optional[str] = None + fetch_method: str + fetch_status: str + fetched_at: datetime + duration_ms: Optional[int] = None + html_size_bytes: Optional[int] = None + errors: Optional[list[str]] = None + notes: Optional[list[str]] = None + + +class WebhookOut(BaseModel): + id: int + event: str + url: str + enabled: bool + secret: Optional[str] = None + created_at: datetime + + +class WebhookCreate(BaseModel): + event: str + url: str + enabled: bool = True + secret: Optional[str] = None + + +class WebhookUpdate(BaseModel): + event: Optional[str] = None + url: Optional[str] = None + enabled: Optional[bool] = None + secret: Optional[str] = None + + +class WebhookTestResponse(BaseModel): + status: str + + +class ScrapingLogCreate(BaseModel): + product_id: Optional[int] = None + url: str + source: str + reference: Optional[str] = None + fetch_method: str + fetch_status: str + fetched_at: datetime + duration_ms: Optional[int] = None + html_size_bytes: Optional[int] = None + errors: Optional[list[str]] = None + notes: Optional[list[str]] = None + + +class ScrapingLogUpdate(BaseModel): + product_id: Optional[int] = None + url: Optional[str] = None + source: Optional[str] = None + reference: Optional[str] = None + fetch_method: Optional[str] = None + fetch_status: Optional[str] = None + fetched_at: Optional[datetime] = None + duration_ms: Optional[int] = None + html_size_bytes: Optional[int] = None + errors: Optional[list[str]] = None + notes: Optional[list[str]] = None + + +class EnqueueRequest(BaseModel): + url: str = Field(..., description="URL du produit") + use_playwright: Optional[bool] = None + save_db: bool = True + + +class EnqueueResponse(BaseModel): + job_id: str + + +class ScheduleRequest(BaseModel): + url: str = Field(..., description="URL du produit") + interval_hours: int = Field(default=24, ge=1) + use_playwright: Optional[bool] = None + save_db: bool = True + + +class ScheduleResponse(BaseModel): + job_id: str + next_run: datetime + + +class ScrapePreviewRequest(BaseModel): + url: str + use_playwright: Optional[bool] = None + + +class ScrapePreviewResponse(BaseModel): + success: bool + snapshot: Optional[dict[str, object]] = None + error: Optional[str] = None + + +class ScrapeCommitRequest(BaseModel): + snapshot: dict[str, object] + + +class ScrapeCommitResponse(BaseModel): + success: bool + product_id: Optional[int] = None + error: Optional[str] = None + + +class VersionResponse(BaseModel): + api_version: str + + +class BackendLogEntry(BaseModel): + time: datetime + level: str + message: str + + +class UvicornLogEntry(BaseModel): + line: str diff --git a/pricewatch/app/cli/__pycache__/main.cpython-313.pyc b/pricewatch/app/cli/__pycache__/main.cpython-313.pyc old mode 100755 new mode 100644 index 0b4bad666c04ab43ad6f7ccc571c15bffeb35cfe..4955cb52e217237d8866c64abb472cd0537bdf67 GIT binary patch delta 6803 zcma(#ZE#apcJJvuNlzb^{2?1#HuxE^k+5Zhu>l#!#@`^BFF(XbED%C^HgYV>?aWTPosJpUX(sz4 zJ?B2jmL0N_JEQaNJ@?*o&OP^hU0u7ue*A!SJu53SGtmC_N7J#$D8u{)2gO!a=sYtB z@3?l9>HJ1wlvqjc7ks97&NR1GiVO@*{wkk?IB4JOulCiD8rrw`*ZFEm9qn8F@BDpt zj&{ra&At}W;@d*D_*}$AaTR{IZ!6hK`<4DyUmIzo{VKo5*G}4L-{J4@Z6n)gzuLdu z*GW2QzsA4A*G0N$f1SVEx0CGj?IOEGCTN_PE1raYl{jziualfoy_X~2pkpJ`U6?05 zL2)A^HFUEYg6xsjI~md|Z3x=CbW)>}nc>MxLGQ#m<;b?Gda<}pYI3g7=%e%Z*m}-5 z@rH7SueNNIHc6YkIuPU%jy=s%OSewr093aG2P9W;u#1=6NPmW1mS$)zrRJ?tD^luW zrMBPz?DeeKG5n1k?Q3>)tl2SAvV%%ju}#`8bxJ#=W~s}oFUoB1mUc?Jq}{-sU&c8l zuhb*$DaCnpQm^z9U^qBWUyzgf;kP$9C=CSl!9hJ7x=WA-*T^thkiqGchUl?|alx`0 z#@32h=9EUHQDjmS1u$dMzBQQrlCKow)x`A!jtAj@lQa&39f03K_<@jJX6eux(e{<_ zE?(wgps;+I6&s}^(u5?fR@e4YUYv9kcm*^?bQz>$Yk2H09=}pLUSdbS64vx)>?fp? z(#z5$9-w&e)@CUvox)>+HJk=EA!KhO`eM#J$Wc||7koUxm2bL-UV2{{@|1rp&`!?D@$ z*;#pnkR(wnqzDPm#S+s};`3@_1_FuobxCRWxfq9xT|tnPKUy?a{O#85{e`fQ?Y3>1QTB6Ps@J6y5@bh zMmE@io3;Ts=r)oX93UChH1b+Ro=eA)39<{3yAgO1^Z;-EM3jud+&5%z3g*j11x;$TUof2OvVz1Tw}c6PX+UbU>-Ayuyyp->$4>!>c*rc0T~sn3)5q zqB8k5ws$!)j;mKVaxg41B!_VhfvkL8-7xQUB-!KN!pam)UP%0LX5?e_W~9^1<Fo`G+4N2QmgQ;oz`kfcHhyXhsio+)Ya{%Lxl01 z9F9(9X2}_(7FOPNb+KoaPh4%4GEVE~!Xzb=<2ZRv(YYrFr?Fdwz<~=4qXYZLM}|o+ zVr&51P7=WWF$8oUcpXEU5d;y8DmUD&20XN?rzcU%BH=_b5sQRpLm4tlUQ>SK-eoz9 z3pgTA5m{;8>SCvrgIhP*sR45&lOXTI{2TL^w*HNt?VE4e#_HIw6j$eoT**CJG>eQQ z*JVwTG!izDITA~x$(uNrL2wDdiqmj}UJDibDom}qU)2zm({dzDZoo_@xK`>X(7D7s zH#5rDo$s+O<-Hvj*^BcvU7vF7%gPtK4i_$5>EFGT4Jk9b-;dL){>xQNOT@*yAr}yw zNAMZ|)e_BUt{94^G#_6_(W!Jeok@W=M`gD`6Zbuz%r^#i^rDwZ=#dh*HHlzZqbr9~`Q)6OP{*%_6cqPim2a0LxDCg_3VYmLSmjVCfr z&p#cw!n2o^i=$&*R8j;G_l3FFqv1q479l?`Ac%ZEBd5|Sa*rZ3<=#^MZ?u^WDC@_D z*hwWk*3FugYhz=%WyNT>x2z4-2$4ONgnS_Y6{;$?Ue%}3QB^)F&!tEsOsIxHY`%4IB_J~=_UCzW}1%0h2#ZGe?*UKSF!rHPqDRR-6W@Iow96dLCQEzr&+X=nNBQ4f^*LDt15QBX16v4-v^Wx7S)+ThgUHcSS^Fe@ zL=mbaIYXdQxDChoYP)&zb$NC+dEwMCnZ%}Iau!Xmup4{|%rPE@9X*pBg>_#wh$5Id zRARs{K9@{^ICG*WniGM3ER{8g;wCYf$=aZTK!gWU6qsj8VUj`Pq0)T*!L@=s8pj?H z3Lt30X`0`#Xape(g?_y~s>sZ_hB?@%lO5go1|g`Pt_zfdO`a@q>=1Sb9k zeP7Ug-@({we-yklx}>w|n;us?ubqDP^v%(qj^7%8w7F|xbJu6p-IvWzgvz(=KeRs- z>Yg_+>(*bFuOI)&xlq%4>Chry^@y)s;A`*L@_g;5{H`ybuV<_kk1X{ImikAQ)&)!J zGsXy%bAl~j(f9GIKfn01i;wybE%YDC_Z`k3KAyLqcqp8FEZD9#-mJLE-Q1bCZ+$4V z0wQl8dMFIjj_0A!4xP)dJrrswqVb_1KDJf8J#%H|dU(Mm<^}N&k1gdzwk->mmS>C( z*dkZgor*i0a0e)NJ`{E+12r|d)Mik_pZ5$jG54D`4EJ#N`v*FQwsZfuy$J@NRBjsX z=054xLqA&^m>3tM8F7w4T#co{bW>Qs%OqR29NEI+@L;yVPAp0nmoU&T?#PVU>h!qZvXI>(wRr0I%Fy=Ww3j0y;_O znr$?XB(zijE>HCWwl2ovaj@TTy4`#i+=b4E65+V))=@POEb20K04Ar2EGJHV{(&O; zl4nI(qZI|`{{UuxkLefuFc*at*R+5@ehE18?+7{&Yy)r*Qu}%w&<9OLfl8<-g-pFm z)y1Nu0at-;N=gfO4AEkZdKRrV@ur|GDCRSj%grRuYV`mrMsup->!ZMod;v4RL$7)Z zI+vKGS^;d&(R_R6uV+8bEI39kn-*>B9@*RrHus%P`D3T@Hh123`Z7oDbL*WQAMU)n z^N#BySKhwomqPECk1gUehNtFxwJvvk`wj1V-s`P5kLB%c4+RhPfYrXS#ub)Ku=%SL z=F#u%>0|!EJXp@%7Y4XN3-`dHhe6i)4YLMgO<;s2U(~|3tl8s%A{Sy~*gKnb?AN-p zki_MLcn%^oR98Y_!E6MBRI#CwXwE)UuxlN)YkfeEcD*6M1lRx%DOu;`SJ@~BHcD+< z&qI%9WaYQ!Dy5tkx*(70ca&tzX)B~}bRyac25&4ectgObg!>(xu+jJ}7LVzZHpreU z7H?AC+}@fqOBR|vtqJ3RvBcubvQ6g01wCPwTQ+$jM>3Fx5@7S>bD-+xR9jIyA$a?e z)E_tfqVjvhtWk9367p;5m$D|vjhcs${{oscBfi)HEF}(5UNqeh4O#Hgz_MC3fE9;g zQB^?NA$SF;rdTQo@5FdGz1A^u^zAQbp7A?mz1lsjoyhA8T zLY~2#4$7_6N6-QHaN57&9#svGsyqu-o<*yDF;~B7vEIGts9!QMj`k%+?=W8$o?97{ zebKh%j_{%Vu06kH_s7P2!lS)M7WN)_w0Cl0@8oBDPv!eh=TA@Pt>=Ev=}hJ?pBor! z(~2W~XFnQV&M5^)!eI5if!cw3=6=0-(8Jwt248Y<4_tZ}s6r?d562Rrko&M|427Wj z2!+T$DR1m=+_(rE={rW%k@JM+a%1728iEOc_?5rg@907MBYObA%O(a-EYziTZB`t& z7fXMPh^m>QXyH%H%GdkrSc_8U^OR%wBRK^0b#GIS`I-z^j*@caPkbAl_+J32L~sSc z@;jVVD?jsXWNVbiK9|z!Z#2c`zYgBucm=s z`X?1N*Bah!xE{Mx_t9{^Us|X*x>zsX=zYI;QLyH19r>Myo^rYd!&7|&yL-`MTjJpR z#8LYk$Im!MXJ67mZ>bNelLAG%*2d(U~H93M(hbp_4?~a-#Pdk z*A+xTeA|me8D6lDEIR5a+sd24Tz>b#1;?Qm3-?`Qv(y)4gYOet#gZPrAe+^`WW=6; oQkt-5E=q|z3R2dy&ZebahPPhMe#X`PiZ}k*$P&l!PI{aF4_(-uqW}N^ delta 3061 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