fplkit 1.4.0__tar.gz → 1.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fplkit-1.4.0 → fplkit-1.4.2}/AGENTS.md +1 -1
- {fplkit-1.4.0 → fplkit-1.4.2}/CHANGELOG.md +37 -2
- {fplkit-1.4.0 → fplkit-1.4.2}/CLAUDE.md +2 -2
- {fplkit-1.4.0 → fplkit-1.4.2}/PKG-INFO +1 -1
- {fplkit-1.4.0 → fplkit-1.4.2}/docs/architecture.md +1 -1
- {fplkit-1.4.0 → fplkit-1.4.2}/docs/command-reference.md +2 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/_version.py +2 -2
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/data/scout.py +8 -2
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/prompts/scout.py +23 -8
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/scraper/fpl_prices.py +78 -29
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_agents_data.py +40 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_fpl_prices.py +16 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_scraper.py +143 -1
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/README.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/TOOLS.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/SKILL.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/references/output-template.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/references/rules.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/squad-builder/SKILL.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/squad-builder/references/output-template.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/squad-builder/references/rules.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.agents/skills/update-gw-prep/SKILL.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.claude/output-styles/fpl-mate.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.claude/settings.json +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.env.example +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.github/workflows/ci.yml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.github/workflows/release.yml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/.gitignore +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/LICENSE +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/README.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/cliff.toml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/config/team_managers.yaml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/config/team_ratings.yaml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/config/team_ratings_overrides.yaml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/docs/custom-analysis.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/docs/fpl-rules.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/docs/images/fpl-player-demo-v1-0.png +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/action/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/action/waiver.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/analysis/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/analysis/bench_order.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/analysis/captain.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/analysis/starting_xi.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/analysis/stats.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/base.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/common.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/data/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/data/fixture.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/data/price.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/orchestration/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/agents/orchestration/report.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/core_insights.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/dataset_fetcher.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/football_data.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/fpl.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/fpl_draft.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/historical.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/historical_types.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/providers/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/providers/_models.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/providers/anthropic.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/providers/openai_compat.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/providers/perplexity.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/understat.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/api/vaastav.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_banner.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_context.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_fines.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_fines_config.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_helpers.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_json.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_league_recap_data.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_league_recap_types.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_plan_grid.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_review_analysis.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_review_classic.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_review_draft.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/_review_summarisation.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/allocate.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/captain.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/chips.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/credentials.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/differentials.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/fdr.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/fixtures.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/history.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/init.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/league.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/league_recap.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/player.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/preview.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/price_changes.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/price_history.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/ratings.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/review.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/sell_prices.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/squad.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/stats.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/status.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/targets.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/transfer_eval.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/waivers.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/cli/xg.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/config/defaults.yaml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/config/fixture_predictions.yaml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/constants.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/models/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/models/chip_plan.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/models/fixture.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/models/player.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/models/team.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/models/types.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/parsers/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/parsers/recommendations.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/paths.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/prompts/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/prompts/league_recap.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/prompts/review.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/scraper/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/season.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/fixture_predictions.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/matchup.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/player_prior.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/player_scoring.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/squad_allocator.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/team_form.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/team_ratings.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/services/team_ratings_prior.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/templates/gw_preview.md.j2 +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/templates/gw_review.md.j2 +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/utils/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/utils/text.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/fpl_cli/utils/time.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/pyproject.toml +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/pyrightconfig.json +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/requirements.lock +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/__init__.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/conftest.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/fixtures/classic_squad_fixture.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/fixtures/recommendations_fixture.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_agents_action.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_agents_analysis.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_agents_analysis_squad.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_agents_base.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_agents_data_price.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_agents_orchestration.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_allocate.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_client.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_core_insights.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_dataset_fetcher.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_draft.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_football_data.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_historical.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_understat.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_api_vaastav.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_bench_order_script.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_classic_squad_extraction.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_allocate.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_banner.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_captain.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_chips.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_credentials.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_custom_analysis.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_differentials.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_fdr.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_fdr_blanks.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_fixtures.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_format.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_format_suppression.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_history.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_init.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_json.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_plan_grid.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_player.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_preview.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_price_history.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_review.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_sell_prices.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_squad.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_stats.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_status.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_targets.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_transfer_eval.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_waivers.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_warnings_stderr.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_cli_xg.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_common.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_common_scoring.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_data_prep_harmonisation.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_fines.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_fines_config.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_fixture_predictions.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_init.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_init_fines.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_league_recap.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_matchup_service.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_models.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_paths.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_player_prior.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_player_resolution.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_player_scoring.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_providers.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_recommendations_parser.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_report_agent.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_review.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_rolling_pts_per_m.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_season.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_sell_prices.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_squad_allocator.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_starting_xi_agent.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_stats.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_team_ratings.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_team_ratings_prior.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_transfer_eval_agent.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_utils_text.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_utils_time.py +0 -0
- {fplkit-1.4.0 → fplkit-1.4.2}/tests/test_validate_draft_waivers.py +0 -0
|
@@ -15,7 +15,7 @@ Agents inherit `fpl_cli/agents/base.py:Agent`, implement `async run(context: dic
|
|
|
15
15
|
|
|
16
16
|
External consumers: `BenchOrderAgent`, `StartingXIAgent`, and `TransferEvalAgent` are imported directly by the gw-prep skill (Obsidian vault) via standalone scripts (`bench_order.py`, `starting_xi.py`, `transfer_eval.py`) that run in fpl-cli's venv. Changes to any agent's interface or import path will break those scripts.
|
|
17
17
|
|
|
18
|
-
API clients in `fpl_cli/api/`: FPLClient (main API, caches `bootstrap-static/`), fpl_draft, perplexity (needs `PERPLEXITY_API_KEY`), understat (scrapes understat.com for npxG/xGChain/xGBuildup), historical data via dual-source architecture: VaastavClient (3 historical seasons 2022-25, keyed on `element_code`) + CoreInsightsClient (current season 2025-26+), composed by `HistoricalDataProvider` (`make_historical_provider()`). Shared types in `historical_types.py` (SeasonHistory, PlayerProfile, GwTrendProfile). Season helper: `season_label()` (alias `vaastav_season()`). Scraper in `fpl_cli/scraper/` (needs `FPL_EMAIL`, `FPL_PASSWORD`). Jinja2 templates in `templates/`.
|
|
18
|
+
API clients in `fpl_cli/api/`: FPLClient (main API, caches `bootstrap-static/`), fpl_draft, perplexity (needs `PERPLEXITY_API_KEY`), understat (scrapes understat.com for npxG/xGChain/xGBuildup), historical data via dual-source architecture: VaastavClient (3 historical seasons 2022-25, keyed on `element_code`) + CoreInsightsClient (current season 2025-26+), composed by `HistoricalDataProvider` (`make_historical_provider()`). Shared types in `historical_types.py` (SeasonHistory, PlayerProfile, GwTrendProfile). Season helper: `season_label()` (alias `vaastav_season()`). Scraper in `fpl_cli/scraper/` (needs `FPL_EMAIL`, `FPL_PASSWORD`; set `FPL_BROWSER_IGNORE_CERTS=1` behind TLS-inspecting proxies — Chromium ignores `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`). Jinja2 templates in `templates/`.
|
|
19
19
|
|
|
20
20
|
### Models (non-obvious aliases)
|
|
21
21
|
- `Player`: attribute is `position` (JSON alias `element_type`), `team_id` (alias `team`), `code` = stable cross-season ID (element_code). Prices in £0.1m units (100 = £10.0m)
|
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
## [1.4.1] - 2026-05-17
|
|
5
|
+
|
|
6
|
+
### Refactoring
|
|
7
|
+
|
|
8
|
+
- refactor FPL scraper to fetch API directly instead of intercepting (#37)
|
|
9
|
+
|
|
10
|
+
## [1.4.0] - 2026-05-17
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- handle bench boost chip in classic team display (#27)
|
|
15
|
+
- chip-aware contributed in league-recap and status (#28)
|
|
16
|
+
- narrative bugs in gameweek review (identity, terminology, captain, grounding, fabrication) (#30)
|
|
17
|
+
- rename Standings to GW Standings; anchor chip count
|
|
18
|
+
- prevent fabricated momentum stats and truncated pFDR rows
|
|
19
|
+
- correct captain hindsight swing formula
|
|
20
|
+
- tighten manager attribution rule in research prompt
|
|
21
|
+
- prevent hallucinated players and fabricated backstories in research tables
|
|
22
|
+
- repair broken tests + harden research validator
|
|
23
|
+
- correctly attribute worst captain to blank-VC managers
|
|
24
|
+
- scope draft waiver validator to its subsection
|
|
25
|
+
- clamp window to season end + ship fixture predictions in package
|
|
26
|
+
- bucket draft txns by league_entry id, not entry_id
|
|
27
|
+
- surface full captain roster to synthesis prompt (#32)
|
|
28
|
+
- scrub fabricated names from research narrative and tables (#33)
|
|
29
|
+
- hit-aware transfer/waiver award net + clearer detail (#34)
|
|
30
|
+
|
|
31
|
+
### Features
|
|
32
|
+
|
|
33
|
+
- localise user-facing times to UK (GMT/BST) (#29)
|
|
34
|
+
- Phase D1 draft waiver validator (#31)
|
|
35
|
+
- contract chain rebuilds in waiver award details
|
|
36
|
+
|
|
37
|
+
### Scraper
|
|
38
|
+
|
|
39
|
+
- honour FPL_BROWSER_IGNORE_CERTS for TLS-inspecting proxies (#35)
|
|
40
|
+
|
|
4
41
|
## [1.3.0] - 2026-04-17
|
|
5
42
|
|
|
6
43
|
### Bug Fixes
|
|
@@ -77,5 +114,3 @@ All notable changes to this project will be documented in this file.
|
|
|
77
114
|
### Features
|
|
78
115
|
|
|
79
116
|
- CI pipeline and PyPI publish for v1.0.0 (#1)
|
|
80
|
-
|
|
81
|
-
|
|
@@ -3,7 +3,7 @@ fpl-cli - CLI tool for Fantasy Premier League analysis (classic + draft).
|
|
|
3
3
|
|
|
4
4
|
## Setup & Dev
|
|
5
5
|
```bash
|
|
6
|
-
source .venv/bin/activate && pip install -e ".[dev]"
|
|
6
|
+
source .venv/bin/activate && pip install -e ".[dev]" # local only — web sessions install globally via setup script
|
|
7
7
|
ruff check fpl_cli/ # Lint
|
|
8
8
|
pyright fpl_cli/ # Type check
|
|
9
9
|
pytest tests/ # Tests
|
|
@@ -15,7 +15,7 @@ Agents inherit `fpl_cli/agents/base.py:Agent`, implement `async run(context: dic
|
|
|
15
15
|
|
|
16
16
|
External consumers: `BenchOrderAgent`, `StartingXIAgent`, and `TransferEvalAgent` are imported directly by the gw-prep skill (Obsidian vault) via standalone scripts (`bench_order.py`, `starting_xi.py`, `transfer_eval.py`) that run in fpl-cli's venv. Changes to any agent's interface or import path will break those scripts.
|
|
17
17
|
|
|
18
|
-
API clients in `fpl_cli/api/`: FPLClient (main API, caches `bootstrap-static/`), fpl_draft, perplexity (needs `PERPLEXITY_API_KEY`), understat (scrapes understat.com for npxG/xGChain/xGBuildup), historical data via dual-source architecture: VaastavClient (3 historical seasons 2022-25, keyed on `element_code`) + CoreInsightsClient (current season 2025-26+), composed by `HistoricalDataProvider` (`make_historical_provider()`). Shared types in `historical_types.py` (SeasonHistory, PlayerProfile, GwTrendProfile). Season helper: `season_label()` (alias `vaastav_season()`). Scraper in `fpl_cli/scraper/` (needs `FPL_EMAIL`, `FPL_PASSWORD`). Jinja2 templates in `templates/`.
|
|
18
|
+
API clients in `fpl_cli/api/`: FPLClient (main API, caches `bootstrap-static/`), fpl_draft, perplexity (needs `PERPLEXITY_API_KEY`), understat (scrapes understat.com for npxG/xGChain/xGBuildup), historical data via dual-source architecture: VaastavClient (3 historical seasons 2022-25, keyed on `element_code`) + CoreInsightsClient (current season 2025-26+), composed by `HistoricalDataProvider` (`make_historical_provider()`). Shared types in `historical_types.py` (SeasonHistory, PlayerProfile, GwTrendProfile). Season helper: `season_label()` (alias `vaastav_season()`). Scraper in `fpl_cli/scraper/` (needs `FPL_EMAIL`, `FPL_PASSWORD`; set `FPL_BROWSER_IGNORE_CERTS=1` behind TLS-inspecting proxies — Chromium ignores `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`). Jinja2 templates in `templates/`.
|
|
19
19
|
|
|
20
20
|
### Models (non-obvious aliases)
|
|
21
21
|
- `Player`: attribute is `position` (JSON alias `element_type`), `team_id` (alias `team`), `code` = stable cross-season ID (element_code). Prices in £0.1m units (100 = £10.0m)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fplkit
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.2
|
|
4
4
|
Summary: CLI tool for Fantasy Premier League analysis - classic and draft formats
|
|
5
5
|
Project-URL: Homepage, https://github.com/rossgroomio/fpl-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/rossgroomio/fpl-cli
|
|
@@ -548,7 +548,7 @@ fpl_cli/
|
|
|
548
548
|
├── parsers/
|
|
549
549
|
│ └── recommendations.py # Parse gw{N}-recommendations.md into structured decisions
|
|
550
550
|
├── scraper/
|
|
551
|
-
│ └── fpl_prices.py # FPLPriceScraper (needs FPL_EMAIL/FPL_PASSWORD)
|
|
551
|
+
│ └── fpl_prices.py # FPLPriceScraper (needs FPL_EMAIL/FPL_PASSWORD; set FPL_BROWSER_IGNORE_CERTS=1 behind TLS-inspecting proxies)
|
|
552
552
|
├── paths.py # SHIPPED_CONFIG_DIR, TEMPLATE_DIR, user_config_dir(), user_data_dir()
|
|
553
553
|
├── season.py # season_label() (+ vaastav_season() alias), TOTAL_GAMEWEEKS, CHIP_SPLIT_GW
|
|
554
554
|
├── constants.py # MIN_MINUTES_FOR_PER90
|
|
@@ -421,6 +421,8 @@ playwright install chromium
|
|
|
421
421
|
fpl credentials set # Store FPL email + password in system keyring
|
|
422
422
|
```
|
|
423
423
|
|
|
424
|
+
**Troubleshooting (TLS-inspecting proxies):** If `--refresh` fails with `ERR_CERT_AUTHORITY_INVALID` (corporate MITM proxy, Zscaler/Netskope, or sandboxed cloud environments like Claude Code on the web), set `FPL_BROWSER_IGNORE_CERTS=1`. Chromium uses its own cert store and ignores `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`, so this flag tells it to skip cert validation on launch. Opt-in only — leave unset on trusted networks.
|
|
425
|
+
|
|
424
426
|
Output: free transfers, bank balance, squad sell prices, total team value. Data cached to `data/team_finances.json` for 12 hours.
|
|
425
427
|
|
|
426
428
|
**Wildcard / Free Hit workflow:** Use `--format json` to export sell prices, then pass to `fpl allocate --sell-prices` for accurate budgeting:
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.4.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
21
|
+
__version__ = version = '1.4.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -49,10 +49,16 @@ class ScoutAgent(Agent):
|
|
|
49
49
|
Returns:
|
|
50
50
|
Formatted position reference string.
|
|
51
51
|
"""
|
|
52
|
-
# Filter to relevant players: ownership > 1% OR form > 3 OR minutes > 500
|
|
52
|
+
# Filter to relevant players: ownership > 1% OR form > 3 OR minutes > 500.
|
|
53
|
+
# Also include any player with active news (e.g. injury return, late-fitness
|
|
54
|
+
# flags) so post-injury returnees with low minutes still appear in the
|
|
55
|
+
# reference and don't force the LLM to guess their club.
|
|
53
56
|
relevant = [
|
|
54
57
|
p for p in players
|
|
55
|
-
if p.selected_by_percent > 1.0
|
|
58
|
+
if p.selected_by_percent > 1.0
|
|
59
|
+
or p.form > 3.0
|
|
60
|
+
or p.minutes > 500
|
|
61
|
+
or bool(p.news)
|
|
56
62
|
]
|
|
57
63
|
|
|
58
64
|
# Group by position
|
|
@@ -34,11 +34,16 @@ ALWAYS:
|
|
|
34
34
|
- Flag confidence level (High/Medium/Low) based on source agreement and recency
|
|
35
35
|
- Interpret signals for the user - don't just report raw quotes
|
|
36
36
|
- Note when trusted voices disagree with mainstream opinion
|
|
37
|
+
- Source every player's current club from the <player_reference> block - never infer it from prior-season knowledge
|
|
38
|
+
- Treat <player_reference> as a strict allowlist: every player you name anywhere in the output (BUY tables, SELL tables, Differential Whispers, Contrarian Take) MUST appear in it
|
|
37
39
|
|
|
38
40
|
NEVER:
|
|
39
41
|
- Repeat statistical analysis (xG, form scores, PPG) - the user has this
|
|
40
42
|
- Give generic advice without specific sourcing
|
|
41
43
|
- Present rumour as fact without flagging uncertainty
|
|
44
|
+
- Guess a player's club from memory; player transfers between seasons make prior knowledge unreliable
|
|
45
|
+
- Surface players who are not in <player_reference>, even if community chatter mentions them - they are not in this season's FPL pool (likely transferred abroad, retired, or never were in the PL)
|
|
46
|
+
- Emit placeholder rows like "PlayerX? — not listed in your FPL pool, so skipping". Silently exclude out-of-pool names instead; return fewer rows (or omit the section) rather than padding
|
|
42
47
|
</rules>"""
|
|
43
48
|
|
|
44
49
|
SCOUT_USER_PROMPT_TEMPLATE = """Surface qualitative FPL intelligence for Gameweek {gameweek}.
|
|
@@ -51,12 +56,21 @@ This query runs 24-30 hours before deadline, after most press conferences.
|
|
|
51
56
|
3. Check r/FantasyPL for community momentum - who's being quietly accumulated, what the RMT thread is converging on
|
|
52
57
|
4. Identify narrative breaks - players being over-sold or under-bought based on reactive sentiment
|
|
53
58
|
5. Note any blank/double GW planning implications from @BenCrellin
|
|
59
|
+
|
|
60
|
+
When scanning external sources, treat any player name you don't find in <player_reference> as out-of-pool: they're not in this season's FPL game (transferred abroad, retired, or wrong league) and must not appear anywhere in your output, even if the community is discussing them.
|
|
54
61
|
</research_focus>
|
|
55
62
|
|
|
56
|
-
<
|
|
57
|
-
|
|
63
|
+
<player_reference>
|
|
64
|
+
Authoritative directory of player → (club, position) for THIS season, sourced from live FPL data. Each entry is formatted as `Name (CLUB)`.
|
|
65
|
+
|
|
66
|
+
Use this as the ONLY source of truth for both:
|
|
67
|
+
- The "Club" column in your output tables (clubs change between seasons; do NOT use prior-season knowledge)
|
|
68
|
+
- The position grouping (DEF / MID / FWD)
|
|
69
|
+
|
|
70
|
+
If a player you want to recommend is not listed here, either omit them or explicitly flag "club unknown" rather than guessing.
|
|
71
|
+
|
|
58
72
|
{position_reference}
|
|
59
|
-
</
|
|
73
|
+
</player_reference>
|
|
60
74
|
|
|
61
75
|
<unavailable_players>
|
|
62
76
|
CRITICAL: The following players are INJURED, SUSPENDED, or otherwise UNAVAILABLE according to current FPL data. Do NOT recommend any of these players under any circumstances, even if web sources suggest them (those sources may be outdated).
|
|
@@ -70,24 +84,24 @@ CRITICAL: The following players are INJURED, SUSPENDED, or otherwise UNAVAILABLE
|
|
|
70
84
|
|
|
71
85
|
| Player | Club | Signal | Source | Confidence |
|
|
72
86
|
|--------|------|--------|--------|------------|
|
|
73
|
-
[
|
|
87
|
+
[Up to 3 players from <player_reference> with qualitative buy signals - what do the watchers see that stats don't show yet? Fewer rows are fine; omit the section entirely if no one in the pool qualifies. Do NOT pad with out-of-pool names.]
|
|
74
88
|
|
|
75
89
|
**MIDFIELDERS**
|
|
76
90
|
|
|
77
91
|
| Player | Club | Signal | Source | Confidence |
|
|
78
92
|
|--------|------|--------|--------|------------|
|
|
79
|
-
[
|
|
93
|
+
[Up to 3 players from <player_reference> with qualitative buy signals. Fewer or zero is acceptable.]
|
|
80
94
|
|
|
81
95
|
**FORWARDS**
|
|
82
96
|
|
|
83
97
|
| Player | Club | Signal | Source | Confidence |
|
|
84
98
|
|--------|------|--------|--------|------------|
|
|
85
|
-
[
|
|
99
|
+
[Up to 2 players from <player_reference> with qualitative buy signals. Fewer or zero is acceptable.]
|
|
86
100
|
|
|
87
101
|
## SELL Signals
|
|
88
102
|
| Player | Club | Signal Type | Detail | Confidence |
|
|
89
103
|
|--------|------|-------------|--------|------------|
|
|
90
|
-
[
|
|
104
|
+
[Up to 5 players from <player_reference> to avoid - categorise as: Rotation Risk, Eye-Test Warning, or Narrative Trap. Fewer rows are fine; omit if no one qualifies.]
|
|
91
105
|
|
|
92
106
|
## Strategic Intel
|
|
93
107
|
- **Blank/Double GW Watch:** Any planning implications from Ben Crellin or fixture news
|
|
@@ -100,7 +114,8 @@ CRITICAL: The following players are INJURED, SUSPENDED, or otherwise UNAVAILABLE
|
|
|
100
114
|
- Confidence = High when multiple trusted sources agree + recent (last 48h); Medium when single source or older; Low when speculative/emerging
|
|
101
115
|
- Prioritise actionable, time-sensitive intel over comprehensive coverage
|
|
102
116
|
- If a trusted voice contradicts the crowd, highlight it
|
|
103
|
-
- CRITICAL: Always check <
|
|
117
|
+
- CRITICAL: Always check <player_reference> before filling the Club column or assigning a player to DEFENDERS/MIDFIELDERS/FORWARDS. The reference reflects current-season clubs; your prior knowledge does not.
|
|
118
|
+
- CRITICAL: <player_reference> is the complete allowlist of in-pool players. If a name you're considering is not in it, drop the row — never emit "Player? — not in pool" placeholders, and never pad to hit a row count.
|
|
104
119
|
</quality_requirements>"""
|
|
105
120
|
|
|
106
121
|
|
|
@@ -193,32 +193,23 @@ class FPLPriceScraper:
|
|
|
193
193
|
)
|
|
194
194
|
page = await context.new_page()
|
|
195
195
|
|
|
196
|
-
# Intercept authenticated API responses before any navigation
|
|
197
|
-
my_entry_response: dict | None = None
|
|
198
|
-
|
|
199
|
-
async def capture_my_entry(response) -> None:
|
|
200
|
-
nonlocal my_entry_response
|
|
201
|
-
if "/api/my-team/" in response.url and response.status == 200:
|
|
202
|
-
try:
|
|
203
|
-
my_entry_response = await response.json()
|
|
204
|
-
except Exception as e: # noqa: BLE001 — scraper resilience
|
|
205
|
-
logger.debug("Failed to capture API JSON: %s", e)
|
|
206
|
-
|
|
207
|
-
page.on("response", capture_my_entry)
|
|
208
|
-
|
|
209
196
|
try:
|
|
210
|
-
# Login to FPL
|
|
211
197
|
await self._login(page, email, password)
|
|
212
198
|
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
|
|
199
|
+
# Verify we're on the FPL domain. If still on the IdP, login silently
|
|
200
|
+
# failed (e.g. selector miss, captcha) and a downstream API fetch would
|
|
201
|
+
# return logged-out HTML rather than JSON.
|
|
202
|
+
if "fantasy.premierleague.com" not in page.url:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Login did not complete - browser still on {page.url}. "
|
|
205
|
+
"The FPL login form may have changed, or credentials were rejected."
|
|
206
|
+
)
|
|
216
207
|
|
|
217
|
-
#
|
|
218
|
-
|
|
219
|
-
|
|
208
|
+
# Fetch the authenticated API directly. Avoids the race between
|
|
209
|
+
# response-interception and page navigation, and works without ever
|
|
210
|
+
# loading the heavy /transfers React shell.
|
|
211
|
+
my_entry_response = await self._fetch_my_team(page)
|
|
220
212
|
|
|
221
|
-
# Extract data
|
|
222
213
|
finances = await self._extract_finances(page, my_entry_response)
|
|
223
214
|
|
|
224
215
|
return finances
|
|
@@ -226,6 +217,51 @@ class FPLPriceScraper:
|
|
|
226
217
|
finally:
|
|
227
218
|
await browser.close()
|
|
228
219
|
|
|
220
|
+
# `or {}` on each step: FPL briefly returns {"player": null} in the post-login
|
|
221
|
+
# window before the session is fully hydrated. .get(key, {}) only defaults on
|
|
222
|
+
# missing keys, not on explicit nulls, so chain through `or {}` to be safe.
|
|
223
|
+
_ME_RETRY_ATTEMPTS = 5
|
|
224
|
+
_ME_RETRY_DELAY_MS = 1000
|
|
225
|
+
|
|
226
|
+
async def _fetch_my_team(self, page) -> dict | None:
|
|
227
|
+
"""Fetch /api/me/ and /api/my-team/{id}/ from the authenticated session.
|
|
228
|
+
|
|
229
|
+
Returns the my-team payload, or None if either call fails (in which case the
|
|
230
|
+
caller falls back to DOM extraction).
|
|
231
|
+
"""
|
|
232
|
+
me: dict | None = None
|
|
233
|
+
entry_id = None
|
|
234
|
+
for attempt in range(self._ME_RETRY_ATTEMPTS):
|
|
235
|
+
try:
|
|
236
|
+
me = await page.evaluate(
|
|
237
|
+
"async () => (await fetch('/api/me/', {credentials: 'include'})).json()"
|
|
238
|
+
)
|
|
239
|
+
except Exception as e: # noqa: BLE001 — scraper resilience
|
|
240
|
+
logger.debug("Failed to fetch /api/me/: %s", e)
|
|
241
|
+
return None
|
|
242
|
+
entry_id = ((me or {}).get("player") or {}).get("entry")
|
|
243
|
+
if entry_id:
|
|
244
|
+
break
|
|
245
|
+
if attempt < self._ME_RETRY_ATTEMPTS - 1:
|
|
246
|
+
await page.wait_for_timeout(self._ME_RETRY_DELAY_MS)
|
|
247
|
+
|
|
248
|
+
if not entry_id:
|
|
249
|
+
logger.debug(
|
|
250
|
+
"No entry id in /api/me/ after %d retries: %s",
|
|
251
|
+
self._ME_RETRY_ATTEMPTS,
|
|
252
|
+
me,
|
|
253
|
+
)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
return await page.evaluate(
|
|
258
|
+
"async (id) => (await fetch(`/api/my-team/${id}/`, {credentials: 'include'})).json()",
|
|
259
|
+
entry_id,
|
|
260
|
+
)
|
|
261
|
+
except Exception as e: # noqa: BLE001 — scraper resilience
|
|
262
|
+
logger.debug("Failed to fetch /api/my-team/%s/: %s", entry_id, e)
|
|
263
|
+
return None
|
|
264
|
+
|
|
229
265
|
async def _login(self, page, email: str, password: str) -> None:
|
|
230
266
|
"""Log in to FPL website."""
|
|
231
267
|
# Start at FPL home page
|
|
@@ -323,11 +359,14 @@ class FPLPriceScraper:
|
|
|
323
359
|
|
|
324
360
|
async def _submit_login(self, page) -> None:
|
|
325
361
|
"""Submit login form and wait for redirect."""
|
|
326
|
-
# Click submit button
|
|
362
|
+
# Click submit button. #btnSignIn is the actual id on the PingOne DaVinci
|
|
363
|
+
# widget; the type='submit' fallback covered older form layouts.
|
|
327
364
|
submit_selectors = [
|
|
365
|
+
"button#btnSignIn",
|
|
328
366
|
"button[type='submit']",
|
|
329
|
-
"button:has-text('
|
|
367
|
+
"button:has-text('Sign in')",
|
|
330
368
|
"button:has-text('Sign In')",
|
|
369
|
+
"button:has-text('Login')",
|
|
331
370
|
"input[type='submit']",
|
|
332
371
|
]
|
|
333
372
|
|
|
@@ -354,12 +393,22 @@ class FPLPriceScraper:
|
|
|
354
393
|
|
|
355
394
|
async def _extract_finances(self, page, my_entry_response: dict | None = None) -> TeamFinances:
|
|
356
395
|
"""Extract financial data - tries intercepted API data first, falls back to DOM."""
|
|
357
|
-
|
|
396
|
+
fallback_reason: str | None = None
|
|
397
|
+
if my_entry_response is None:
|
|
398
|
+
fallback_reason = (
|
|
399
|
+
"API /api/me/ returned no entry id after retries (post-login hydration race?)"
|
|
400
|
+
)
|
|
401
|
+
else:
|
|
358
402
|
finances = await self._extract_from_intercepted(page, my_entry_response)
|
|
359
403
|
if finances and not finances.is_suspect:
|
|
360
404
|
return finances
|
|
405
|
+
fallback_reason = (
|
|
406
|
+
"API /api/my-team/ returned no usable squad data; fell back to DOM extraction"
|
|
407
|
+
)
|
|
361
408
|
|
|
362
|
-
|
|
409
|
+
finances = await self._extract_via_dom(page)
|
|
410
|
+
finances.extraction_errors.append(fallback_reason)
|
|
411
|
+
return finances
|
|
363
412
|
|
|
364
413
|
async def _extract_from_intercepted(self, page, my_entry_response: dict) -> TeamFinances | None:
|
|
365
414
|
"""Build TeamFinances from intercepted /api/my-team/ response data."""
|
|
@@ -380,10 +429,10 @@ class FPLPriceScraper:
|
|
|
380
429
|
elements = {}
|
|
381
430
|
element_types = {}
|
|
382
431
|
|
|
383
|
-
picks = my_entry_response.get("picks"
|
|
384
|
-
transfers = my_entry_response.get("transfers"
|
|
385
|
-
bank = transfers.get("bank"
|
|
386
|
-
free_transfers = transfers.get("limit"
|
|
432
|
+
picks = my_entry_response.get("picks") or []
|
|
433
|
+
transfers = my_entry_response.get("transfers") or {}
|
|
434
|
+
bank = (transfers.get("bank") or 0) / 10.0
|
|
435
|
+
free_transfers = transfers.get("limit") or 0
|
|
387
436
|
|
|
388
437
|
squad = []
|
|
389
438
|
for pick in picks:
|
|
@@ -1206,6 +1206,46 @@ Sources:
|
|
|
1206
1206
|
assert isinstance(result.data["content_clean"], str)
|
|
1207
1207
|
assert isinstance(result.data["citations"], list)
|
|
1208
1208
|
|
|
1209
|
+
def test_build_position_reference_includes_news_flagged_players(self, agent):
|
|
1210
|
+
"""Low-minutes players with active news (e.g. injury returnees) must
|
|
1211
|
+
appear in the reference so the LLM doesn't have to guess their club."""
|
|
1212
|
+
from fpl_cli.models.player import PlayerPosition
|
|
1213
|
+
from tests.conftest import make_player
|
|
1214
|
+
|
|
1215
|
+
# A defender just back from injury: low ownership, low form, low
|
|
1216
|
+
# minutes — would be excluded by the old filter, but has news.
|
|
1217
|
+
returnee = make_player(
|
|
1218
|
+
id=99,
|
|
1219
|
+
web_name="Returnee",
|
|
1220
|
+
team_id=7,
|
|
1221
|
+
position=PlayerPosition.DEFENDER,
|
|
1222
|
+
selected_by_percent=0.3,
|
|
1223
|
+
form=1.0,
|
|
1224
|
+
minutes=120,
|
|
1225
|
+
news="Back in training, hopeful for involvement",
|
|
1226
|
+
)
|
|
1227
|
+
ref = agent.build_position_reference([returnee], {7: "MCI"})
|
|
1228
|
+
assert "Returnee (MCI)" in ref
|
|
1229
|
+
|
|
1230
|
+
def test_build_position_reference_excludes_irrelevant_players(self, agent):
|
|
1231
|
+
"""Players with no news, no minutes, no form, and tiny ownership
|
|
1232
|
+
should still be excluded to keep the reference compact."""
|
|
1233
|
+
from fpl_cli.models.player import PlayerPosition
|
|
1234
|
+
from tests.conftest import make_player
|
|
1235
|
+
|
|
1236
|
+
irrelevant = make_player(
|
|
1237
|
+
id=100,
|
|
1238
|
+
web_name="Bench",
|
|
1239
|
+
team_id=7,
|
|
1240
|
+
position=PlayerPosition.DEFENDER,
|
|
1241
|
+
selected_by_percent=0.1,
|
|
1242
|
+
form=0.0,
|
|
1243
|
+
minutes=0,
|
|
1244
|
+
news="",
|
|
1245
|
+
)
|
|
1246
|
+
ref = agent.build_position_reference([irrelevant], {7: "MCI"})
|
|
1247
|
+
assert "Bench" not in ref
|
|
1248
|
+
|
|
1209
1249
|
async def test_run_cleans_citations_correctly(self, agent):
|
|
1210
1250
|
"""Test that citations are properly cleaned from content."""
|
|
1211
1251
|
response_with_citations = LLMResponse(
|
|
@@ -89,3 +89,19 @@ class TestExtractFromIntercepted:
|
|
|
89
89
|
"""DOM fallback produces PlayerSellPrice with element_id=None."""
|
|
90
90
|
p = PlayerSellPrice(name="Salah", sell_price=13.0)
|
|
91
91
|
assert p.element_id is None
|
|
92
|
+
|
|
93
|
+
def test_extract_handles_null_transfers_field(self):
|
|
94
|
+
"""FPL briefly returns {'transfers': null} post-login; must not crash."""
|
|
95
|
+
import asyncio
|
|
96
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
97
|
+
|
|
98
|
+
scraper_mod = __import__("fpl_cli.scraper.fpl_prices", fromlist=["FPLPriceScraper"])
|
|
99
|
+
scraper = scraper_mod.FPLPriceScraper()
|
|
100
|
+
|
|
101
|
+
page = MagicMock()
|
|
102
|
+
page.evaluate = AsyncMock(return_value={"elements": [], "element_types": []})
|
|
103
|
+
|
|
104
|
+
my_entry = {"picks": None, "transfers": None}
|
|
105
|
+
result = asyncio.run(scraper._extract_from_intercepted(page, my_entry))
|
|
106
|
+
# No picks -> _extract_from_intercepted returns None, caller falls back to DOM
|
|
107
|
+
assert result is None
|
|
@@ -139,20 +139,23 @@ class TestFPLPriceScraper:
|
|
|
139
139
|
mock_login = AsyncMock()
|
|
140
140
|
mock_extract = AsyncMock()
|
|
141
141
|
mock_extract.return_value = TeamFinances(bank=0.0, free_transfers=0, squad=[], total_value=0.0)
|
|
142
|
+
mock_fetch_my_team = AsyncMock(return_value=None)
|
|
142
143
|
|
|
143
144
|
@contextmanager
|
|
144
145
|
def ctx():
|
|
145
146
|
with patch.object(scraper, "_login", mock_login), \
|
|
146
147
|
patch.object(scraper, "_extract_finances", mock_extract), \
|
|
148
|
+
patch.object(scraper, "_fetch_my_team", mock_fetch_my_team), \
|
|
147
149
|
patch.object(scraper, "_accept_cookies", AsyncMock()), \
|
|
148
150
|
patch("playwright.async_api.async_playwright") as mock_pw:
|
|
149
151
|
mock_p = AsyncMock()
|
|
150
152
|
mock_page = AsyncMock()
|
|
153
|
+
# scrape() reads page.url after login to detect IdP-stuck failures.
|
|
154
|
+
mock_page.url = "https://fantasy.premierleague.com/"
|
|
151
155
|
mock_pw.return_value.__aenter__.return_value = mock_p
|
|
152
156
|
mock_p.chromium.launch.return_value = AsyncMock()
|
|
153
157
|
mock_p.chromium.launch.return_value.new_context.return_value = AsyncMock()
|
|
154
158
|
mock_p.chromium.launch.return_value.new_context.return_value.new_page.return_value = mock_page
|
|
155
|
-
mock_page.on = lambda *_a, **_k: None
|
|
156
159
|
yield
|
|
157
160
|
|
|
158
161
|
return mock_login, ctx
|
|
@@ -209,6 +212,145 @@ class TestFPLPriceScraper:
|
|
|
209
212
|
assert call_email == "keyring@example.com"
|
|
210
213
|
assert call_password == "keyring_pass"
|
|
211
214
|
|
|
215
|
+
async def test_scrape_raises_when_login_leaves_browser_on_idp(self):
|
|
216
|
+
"""If login silently fails, browser stays on account.premierleague.com - raise clearly."""
|
|
217
|
+
pytest.importorskip("playwright")
|
|
218
|
+
from contextlib import contextmanager
|
|
219
|
+
from unittest.mock import AsyncMock
|
|
220
|
+
|
|
221
|
+
with patch.dict(os.environ, {"FPL_EMAIL": "x@y.z", "FPL_PASSWORD": "p"}):
|
|
222
|
+
scraper = FPLPriceScraper()
|
|
223
|
+
|
|
224
|
+
@contextmanager
|
|
225
|
+
def ctx():
|
|
226
|
+
with patch.object(scraper, "_login", AsyncMock()), \
|
|
227
|
+
patch("playwright.async_api.async_playwright") as mock_pw:
|
|
228
|
+
mock_p = AsyncMock()
|
|
229
|
+
mock_page = AsyncMock()
|
|
230
|
+
mock_page.url = "https://account.premierleague.com/as/authorize?..."
|
|
231
|
+
mock_pw.return_value.__aenter__.return_value = mock_p
|
|
232
|
+
mock_p.chromium.launch.return_value = AsyncMock()
|
|
233
|
+
mock_p.chromium.launch.return_value.new_context.return_value = AsyncMock()
|
|
234
|
+
mock_p.chromium.launch.return_value.new_context.return_value.new_page.return_value = mock_page
|
|
235
|
+
yield
|
|
236
|
+
|
|
237
|
+
with ctx(), pytest.raises(ValueError, match="Login did not complete"):
|
|
238
|
+
await scraper.scrape()
|
|
239
|
+
|
|
240
|
+
async def test_fetch_my_team_returns_payload(self):
|
|
241
|
+
"""_fetch_my_team chains /api/me/ -> /api/my-team/{id}/ via page.evaluate."""
|
|
242
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
243
|
+
|
|
244
|
+
scraper = FPLPriceScraper()
|
|
245
|
+
page = MagicMock()
|
|
246
|
+
page.evaluate = AsyncMock(side_effect=[
|
|
247
|
+
{"player": {"entry": 5955459}},
|
|
248
|
+
{"picks": [{"element": 253, "selling_price": 130, "purchase_price": 130}],
|
|
249
|
+
"transfers": {"bank": 29, "limit": 1}},
|
|
250
|
+
])
|
|
251
|
+
|
|
252
|
+
result = await scraper._fetch_my_team(page)
|
|
253
|
+
assert result is not None
|
|
254
|
+
assert result["transfers"]["bank"] == 29
|
|
255
|
+
# Second evaluate gets the entry id as a positional arg
|
|
256
|
+
assert page.evaluate.call_args_list[1].args[1] == 5955459
|
|
257
|
+
|
|
258
|
+
async def test_fetch_my_team_returns_none_on_missing_entry(self):
|
|
259
|
+
"""If /api/me/ never returns a player.entry, abort and let DOM fallback take over."""
|
|
260
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
261
|
+
|
|
262
|
+
scraper = FPLPriceScraper()
|
|
263
|
+
page = MagicMock()
|
|
264
|
+
page.evaluate = AsyncMock(return_value={})
|
|
265
|
+
page.wait_for_timeout = AsyncMock()
|
|
266
|
+
|
|
267
|
+
result = await scraper._fetch_my_team(page)
|
|
268
|
+
assert result is None
|
|
269
|
+
assert page.evaluate.call_count == scraper._ME_RETRY_ATTEMPTS
|
|
270
|
+
# No trailing wait after the final failed attempt
|
|
271
|
+
assert page.wait_for_timeout.call_count == scraper._ME_RETRY_ATTEMPTS - 1
|
|
272
|
+
|
|
273
|
+
async def test_fetch_my_team_handles_null_player(self):
|
|
274
|
+
"""FPL briefly returns {'player': null} post-login; we retry then give up cleanly."""
|
|
275
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
276
|
+
|
|
277
|
+
scraper = FPLPriceScraper()
|
|
278
|
+
page = MagicMock()
|
|
279
|
+
page.evaluate = AsyncMock(return_value={"player": None})
|
|
280
|
+
page.wait_for_timeout = AsyncMock()
|
|
281
|
+
|
|
282
|
+
result = await scraper._fetch_my_team(page)
|
|
283
|
+
assert result is None
|
|
284
|
+
assert page.evaluate.call_count == scraper._ME_RETRY_ATTEMPTS
|
|
285
|
+
# No trailing wait after the final failed attempt
|
|
286
|
+
assert page.wait_for_timeout.call_count == scraper._ME_RETRY_ATTEMPTS - 1
|
|
287
|
+
|
|
288
|
+
async def test_fetch_my_team_retries_until_hydrated(self):
|
|
289
|
+
"""Null player on early polls, then a real entry id: retry succeeds and fetches my-team."""
|
|
290
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
291
|
+
|
|
292
|
+
scraper = FPLPriceScraper()
|
|
293
|
+
page = MagicMock()
|
|
294
|
+
page.evaluate = AsyncMock(side_effect=[
|
|
295
|
+
{"player": None},
|
|
296
|
+
{"player": None},
|
|
297
|
+
{"player": {"entry": 12345}},
|
|
298
|
+
{"picks": [{"element": 1, "selling_price": 50, "purchase_price": 50}],
|
|
299
|
+
"transfers": {"bank": 10, "limit": 1}},
|
|
300
|
+
])
|
|
301
|
+
page.wait_for_timeout = AsyncMock()
|
|
302
|
+
|
|
303
|
+
result = await scraper._fetch_my_team(page)
|
|
304
|
+
assert result is not None
|
|
305
|
+
assert result["transfers"]["bank"] == 10
|
|
306
|
+
# 3 /api/me/ calls + 1 /api/my-team/ call
|
|
307
|
+
assert page.evaluate.call_count == 4
|
|
308
|
+
# And we waited between the failing /api/me/ attempts (twice)
|
|
309
|
+
assert page.wait_for_timeout.call_count == 2
|
|
310
|
+
|
|
311
|
+
async def test_fetch_my_team_evaluate_error_no_retry(self):
|
|
312
|
+
"""A hard page.evaluate exception is a different failure mode - don't retry."""
|
|
313
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
314
|
+
|
|
315
|
+
scraper = FPLPriceScraper()
|
|
316
|
+
page = MagicMock()
|
|
317
|
+
page.evaluate = AsyncMock(side_effect=RuntimeError("eval failed"))
|
|
318
|
+
page.wait_for_timeout = AsyncMock()
|
|
319
|
+
|
|
320
|
+
result = await scraper._fetch_my_team(page)
|
|
321
|
+
assert result is None
|
|
322
|
+
assert page.evaluate.call_count == 1
|
|
323
|
+
assert page.wait_for_timeout.call_count == 0
|
|
324
|
+
|
|
325
|
+
async def test_extract_finances_records_api_failure_in_errors(self):
|
|
326
|
+
"""When the API path is skipped, the DOM fallback's result gets a hint appended."""
|
|
327
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
328
|
+
|
|
329
|
+
scraper = FPLPriceScraper()
|
|
330
|
+
dom_result = TeamFinances(bank=0.0, free_transfers=0, squad=[], total_value=0.0)
|
|
331
|
+
with patch.object(scraper, "_extract_via_dom", AsyncMock(return_value=dom_result)):
|
|
332
|
+
result = await scraper._extract_finances(MagicMock(), my_entry_response=None)
|
|
333
|
+
|
|
334
|
+
assert result is dom_result
|
|
335
|
+
assert any("api/me" in err.lower() for err in result.extraction_errors)
|
|
336
|
+
|
|
337
|
+
async def test_extract_finances_records_intercepted_failure_in_errors(self):
|
|
338
|
+
"""If /api/me/ succeeded but the intercepted payload is unusable, the DOM fallback still gets a hint."""
|
|
339
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
340
|
+
|
|
341
|
+
scraper = FPLPriceScraper()
|
|
342
|
+
dom_result = TeamFinances(bank=0.0, free_transfers=0, squad=[], total_value=0.0)
|
|
343
|
+
with (
|
|
344
|
+
patch.object(scraper, "_extract_from_intercepted", AsyncMock(return_value=None)),
|
|
345
|
+
patch.object(scraper, "_extract_via_dom", AsyncMock(return_value=dom_result)),
|
|
346
|
+
):
|
|
347
|
+
result = await scraper._extract_finances(
|
|
348
|
+
MagicMock(), my_entry_response={"picks": [], "transfers": {}}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
assert result is dom_result
|
|
352
|
+
assert any("my-team" in err.lower() for err in result.extraction_errors)
|
|
353
|
+
|
|
212
354
|
def test_cache_file_path(self):
|
|
213
355
|
"""Test cache file path is correct."""
|
|
214
356
|
assert CACHE_FILE.name == "team_finances.json"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|