From 4e60524f37827bdc20aec254d04059a73dbc97a6 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Wed, 7 Oct 2026 09:58:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=91=E6=8E=A7=E9=9D=A2=E6=9D=BF=20?= =?UTF-8?q?/=20=E7=99=BB=E5=BD=95=E9=89=B4=E6=9D=83=20/=20=E5=A4=9A?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=88=87=E6=8D=A2=20/=20MySQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在上游 MediaCrawler 之上新增一层: - 监控层 api/monitor/ —— 多博主/多笔记的定时采集、指标快照差分、报表、 企业微信通知。每轮采集写入独立目录,差分才成立。 - WebUI 登录鉴权 api/auth.py —— PBKDF2 口令 + 服务端会话,/api 全接口防护。 WebSocket 单独加依赖:BaseHTTPMiddleware 对 ws 作用域直接放行,覆盖不到。 - 全局平台切换 + 能力矩阵 —— 如实区分「爬虫模块支持」与「监控层已接线」, 未接通的平台直接拒绝建任务,而不是静默跑空。 - 监控库改用 MySQL 5.7(可回退 SQLite 供测试):逐表强制 utf8mb4 (服务端与库默认都是 latin1),启动校验所连 schema 以防写错库, 连接池 recycle + pre_ping 应对 MySQL 的 8 小时空闲断连。 修复上游缺陷: - xhs/core.py: 主页抓取失败会跳掉整个博主,导致一条作品都抓不到, 而那份资料只喂给一个空函数。改为尽力而为,失败不中断。 - xhs/login.py: cookie 登录只注入 web_session,冷启动签名会失败。 新增 INJECT_ALL_COOKIES 开关(默认关闭,原有行为不变)。 - requirements.txt: 补上 websockets。它在上游 pyproject.toml 里有声明、 这里漏了,导致 uvicorn 没有 WebSocket 能力,实时日志流从未工作。 改动过的上游文件清单及合并方式见 UPSTREAM.md。 测试:492 passed(另有 1 个既有的 Windows/gbk 上游测试失败,与本改动无关) --- UPSTREAM.md | 129 ++++ api/auth.py | 369 +++++++++ api/main.py | 182 +++-- api/monitor/__init__.py | 19 + api/monitor/app_settings.py | 395 ++++++++++ api/monitor/db.py | 293 +++++++ api/monitor/ingest.py | 589 ++++++++++++++ api/monitor/migrate_from_sqlite.py | 177 +++++ api/monitor/models.py | 367 +++++++++ api/monitor/notify.py | 200 +++++ api/monitor/platforms.py | 197 +++++ api/monitor/report.py | 203 +++++ api/monitor/runner.py | 277 +++++++ api/monitor/scheduler.py | 185 +++++ api/monitor/service.py | 720 ++++++++++++++++++ api/monitor/settings.py | 108 +++ api/routers/__init__.py | 12 +- api/routers/auth.py | 173 +++++ api/routers/crawler.py | 27 +- api/routers/monitor.py | 502 ++++++++++++ api/routers/settings.py | 70 ++ api/routers/websocket.py | 10 +- api/schemas/auth.py | 34 + api/schemas/crawler.py | 28 + api/schemas/monitor.py | 81 ++ api/schemas/settings.py | 37 + api/services/crawler_manager.py | 113 ++- api/services/interpreter.py | 70 ++ cmd_arg/arg.py | 59 ++ config/base_config.py | 6 + docs/监控功能使用说明.md | 476 ++++++++++++ media_platform/xhs/core.py | 25 +- media_platform/xhs/login.py | 11 +- requirements.txt | 6 +- tests/conftest.py | 29 + tests/test_auth.py | 503 ++++++++++++ tests/test_cmd_arg_monitor_flags.py | 128 ++++ tests/test_interpreter.py | 67 ++ tests/test_monitor_api.py | 208 +++++ tests/test_monitor_comments.py | 232 ++++++ tests/test_monitor_ingest.py | 527 +++++++++++++ tests/test_monitor_notify.py | 315 ++++++++ tests/test_monitor_report.py | 304 ++++++++ tests/test_monitor_scheduler.py | 266 +++++++ tests/test_platforms.py | 302 ++++++++ tests/test_settings.py | 374 +++++++++ webui/index.html | 2 +- webui/public/logos/bilibili_logo.png | Bin 43010 -> 0 bytes webui/public/logos/douyin.png | Bin 25846 -> 0 bytes webui/public/logos/github.png | Bin 7967 -> 0 bytes webui/public/logos/my_logo.png | Bin 319037 -> 0 bytes webui/public/logos/xiaohongshu_logo.png | Bin 6360 -> 0 bytes webui/src/App.tsx | 100 ++- webui/src/components/auth/Login.tsx | 111 +++ .../components/config/CrawlerConfigPanel.tsx | 68 +- webui/src/components/layout/AuthorFooter.tsx | 80 -- .../components/layout/PlatformSwitcher.tsx | 82 ++ webui/src/components/layout/Sidebar.tsx | 104 ++- .../layout/UnwiredPlatformNotice.tsx | 105 +++ .../components/license/LicenseDisclaimer.tsx | 144 ---- webui/src/components/monitor/CommentsFeed.tsx | 305 ++++++++ webui/src/components/monitor/CookiePanel.tsx | 133 ++++ webui/src/components/monitor/EventsFeed.tsx | 110 +++ .../components/monitor/MonitorDashboard.tsx | 198 +++++ .../src/components/monitor/NoteTrendChart.tsx | 209 +++++ webui/src/components/monitor/NotesTable.tsx | 146 ++++ webui/src/components/monitor/ReportView.tsx | 302 ++++++++ webui/src/components/monitor/RunHistory.tsx | 96 +++ webui/src/components/monitor/StatTile.tsx | 48 ++ webui/src/components/monitor/TaskCard.tsx | 156 ++++ .../components/monitor/TaskEditorDialog.tsx | 281 +++++++ webui/src/components/monitor/WebhookPanel.tsx | 125 +++ .../src/components/settings/SettingFields.tsx | 164 ++++ .../src/components/settings/SettingsView.tsx | 141 ++++ .../settings/SystemSettingsDialog.tsx | 122 +++ webui/src/hooks/useMonitor.ts | 286 +++++++ webui/src/hooks/usePlatform.ts | 27 + webui/src/i18n/index.ts | 4 - webui/src/i18n/locales/en-US/common.json | 2 +- webui/src/i18n/locales/en-US/env.json | 2 +- webui/src/i18n/locales/en-US/license.json | 21 - webui/src/i18n/locales/zh-CN/common.json | 2 +- webui/src/i18n/locales/zh-CN/env.json | 2 +- webui/src/i18n/locales/zh-CN/license.json | 21 - webui/src/lib/api.ts | 170 ++++- webui/src/lib/monitorFormat.ts | 44 ++ webui/src/store/platformStore.ts | 43 ++ webui/src/types/monitor.ts | 299 ++++++++ 88 files changed, 13224 insertions(+), 436 deletions(-) create mode 100644 UPSTREAM.md create mode 100644 api/auth.py create mode 100644 api/monitor/__init__.py create mode 100644 api/monitor/app_settings.py create mode 100644 api/monitor/db.py create mode 100644 api/monitor/ingest.py create mode 100644 api/monitor/migrate_from_sqlite.py create mode 100644 api/monitor/models.py create mode 100644 api/monitor/notify.py create mode 100644 api/monitor/platforms.py create mode 100644 api/monitor/report.py create mode 100644 api/monitor/runner.py create mode 100644 api/monitor/scheduler.py create mode 100644 api/monitor/service.py create mode 100644 api/monitor/settings.py create mode 100644 api/routers/auth.py create mode 100644 api/routers/monitor.py create mode 100644 api/routers/settings.py create mode 100644 api/schemas/auth.py create mode 100644 api/schemas/monitor.py create mode 100644 api/schemas/settings.py create mode 100644 api/services/interpreter.py create mode 100644 docs/监控功能使用说明.md create mode 100644 tests/test_auth.py create mode 100644 tests/test_cmd_arg_monitor_flags.py create mode 100644 tests/test_interpreter.py create mode 100644 tests/test_monitor_api.py create mode 100644 tests/test_monitor_comments.py create mode 100644 tests/test_monitor_ingest.py create mode 100644 tests/test_monitor_notify.py create mode 100644 tests/test_monitor_report.py create mode 100644 tests/test_monitor_scheduler.py create mode 100644 tests/test_platforms.py create mode 100644 tests/test_settings.py delete mode 100644 webui/public/logos/bilibili_logo.png delete mode 100644 webui/public/logos/douyin.png delete mode 100644 webui/public/logos/github.png delete mode 100644 webui/public/logos/my_logo.png delete mode 100644 webui/public/logos/xiaohongshu_logo.png create mode 100644 webui/src/components/auth/Login.tsx delete mode 100644 webui/src/components/layout/AuthorFooter.tsx create mode 100644 webui/src/components/layout/PlatformSwitcher.tsx create mode 100644 webui/src/components/layout/UnwiredPlatformNotice.tsx delete mode 100644 webui/src/components/license/LicenseDisclaimer.tsx create mode 100644 webui/src/components/monitor/CommentsFeed.tsx create mode 100644 webui/src/components/monitor/CookiePanel.tsx create mode 100644 webui/src/components/monitor/EventsFeed.tsx create mode 100644 webui/src/components/monitor/MonitorDashboard.tsx create mode 100644 webui/src/components/monitor/NoteTrendChart.tsx create mode 100644 webui/src/components/monitor/NotesTable.tsx create mode 100644 webui/src/components/monitor/ReportView.tsx create mode 100644 webui/src/components/monitor/RunHistory.tsx create mode 100644 webui/src/components/monitor/StatTile.tsx create mode 100644 webui/src/components/monitor/TaskCard.tsx create mode 100644 webui/src/components/monitor/TaskEditorDialog.tsx create mode 100644 webui/src/components/monitor/WebhookPanel.tsx create mode 100644 webui/src/components/settings/SettingFields.tsx create mode 100644 webui/src/components/settings/SettingsView.tsx create mode 100644 webui/src/components/settings/SystemSettingsDialog.tsx create mode 100644 webui/src/hooks/useMonitor.ts create mode 100644 webui/src/hooks/usePlatform.ts delete mode 100644 webui/src/i18n/locales/en-US/license.json delete mode 100644 webui/src/i18n/locales/zh-CN/license.json create mode 100644 webui/src/lib/monitorFormat.ts create mode 100644 webui/src/store/platformStore.ts create mode 100644 webui/src/types/monitor.ts diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 0000000..b6b74ae --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,129 @@ +# 与上游的差异管理 + +本仓库在 [NanmiCoder/MediaCrawler](https://github.com/NanmiCoder/MediaCrawler) 之上加了一层 +监控/鉴权/多平台面板。这份文档记录**改了上游哪些文件、为什么**,以及**上游更新时怎么合并**。 + +--- + +## 一、改动分三类 + +冲突风险从低到高: + +### 1. 纯新增文件(零冲突) + +上游怎么改都不会碰到它们: + +``` +api/auth.py WebUI 登录鉴权 +api/monitor/* 监控层整体(含 platforms.py 能力矩阵) +api/monitor/db.py MySQL 连接层(可回退 SQLite 供测试用) +api/monitor/migrate_from_sqlite.py SQLite → MySQL 一次性迁移脚本 +api/routers/{auth,monitor,settings}.py +api/schemas/{auth,monitor,settings}.py +api/services/interpreter.py 解释器探测(uv / .venv / 当前解释器) +webui/src/components/{monitor,settings,auth}/ 新视图 +webui/src/components/layout/{PlatformSwitcher,UnwiredPlatformNotice}.tsx +webui/src/{hooks/useMonitor.ts,hooks/usePlatform.ts,store/platformStore.ts,lib/monitorFormat.ts,types/monitor.ts} +docs/监控功能使用说明.md +tests/test_{auth,settings,platforms,monitor_*}.py +``` + +### 2. 加法改动(低冲突) + +只在既有文件里**新增**内容,不改动原有行: + +| 文件 | 加了什么 | +|---|---| +| `cmd_arg/arg.py` | typer 选项:`--enable_cdp_mode`、`--inject_all_cookies`、`--save_login_state`、`--cookies_file`、`--crawler_max_sleep_sec`,以及对应的 `config.*` 回写 | +| `api/schemas/crawler.py` | `CrawlerStartRequest` 的若干**可选**字段(默认 `None`,不传则不加对应 CLI 参数) | +| `config/base_config.py` | `INJECT_ALL_COOKIES = False` | +| `api/routers/__init__.py` | 导出新增的 router | +| `requirements.txt` | 补上 `websockets`(上游 `pyproject.toml` 里有、`requirements.txt` 里漏了) | +| `tests/conftest.py` | 新增 `_bypass_auth_for_non_auth_suites` fixture | + +### 3. 接线改动(中冲突,需要人看) + +| 文件 | 改了什么 | 上游若在此处变动 | +|---|---|---| +| `api/main.py` | 注册 4 个 router 并加 `Depends(require_auth)`;`lifespan` 里初始化监控库、启动调度器、跑设置键迁移;`load_dotenv`;CORS 可配;`docs/redoc/openapi` 关闭;监听地址改 env | **最需要人工合并的文件**。留意 router 注册块、lifespan、`__main__` | +| `api/routers/websocket.py` | 两个 WS 路由加 `dependencies=[Depends(require_ws_auth)]` | 上游若新增 WS 路由,**必须同样加上**,否则那条流是裸奔的 | +| `api/services/crawler_manager.py` | 解释器探测替换硬编码 `uv run`;`_build_command` 转发新增参数;新增 `is_busy()` / `run_and_wait()` 与完成事件 | 留意 `_build_command` 的参数拼装 | +| `media_platform/xhs/login.py` | `login_by_cookies` 在 `INJECT_ALL_COOKIES` 打开时注入**全部** cookie(默认关闭,行为不变) | 小改动,好合并 | + +### 4. 上游 bug 修复(建议回馈上游) + +| 文件 | 修的问题 | +|---|---| +| `media_platform/xhs/core.py` | 见下节 | +| `media_platform/xhs/login.py` | 同上(cookie 加固) | + +--- + +## 二、应该给上游提 PR 的两个修复 + +这两处是**上游自身的缺陷**,提上去以后就不用自己背着: + +### 1. 博主主页抓取失败会跳掉整个博主(`xhs/core.py`) + +`get_creator_info()` 抓主页 HTML 解析 `window.__INITIAL_STATE__`,解析失败抛 `JSONDecodeError`—— +它是 `ValueError` 的子类,被 `except ValueError` 误捕获,日志报成 +"Failed to parse creator URL"(**误导**,URL 根本没解析错),然后 `continue` **跳过整个博主**。 + +而那份资料只喂给 `save_creator()`,它在教学版里是**空函数**。也就是说: +一个喂给空函数的抓取失败,让真正要抓的作品一条都没抓到,表现为"0 篇作品", +和"登录失效"长得一模一样。 + +修复:把资料抓取改成**尽力而为**,失败只警告、继续抓作品。 + +### 2. cookie 登录只注入 `web_session`(`xhs/login.py`) + +`a1` / `webId` 等签名所需 cookie 只能靠持久化 profile 补,冷启动时签名会失败。 +默认行为保持不变,用 `INJECT_ALL_COOKIES` 开关控制。 + +--- + +## 三、上游更新时怎么操作 + +### 日常流程 + +```bash +git stash # 或先 commit 到自己的分支(推荐) +git fetch origin main +git rebase origin/main # 冲突只会出现在上表第 3、4 类文件里 +./.venv/Scripts/python.exe -m pytest tests/ -q # 486 个测试就是回归网 +``` + +### 强烈建议:先把改动提交掉 + +当前状态是**未提交**的(25 个上游文件被改 + 31 个新文件)。在 `main` 分支上裸着工作区, +一次 `git checkout .` 就全没了,而且没法 rebase。 + +```bash +git checkout -b local/monitor-panel +git add -A && git commit -m "监控面板 / 鉴权 / 多平台" +``` + +### 如果改动持续增长:fork + +把本仓库 fork 到自己名下,上游设为 remote: + +```bash +git remote rename origin upstream +git remote add origin <你的 fork> +git push -u origin local/monitor-panel +``` + +之后同步上游用 `git fetch upstream && git rebase upstream/main`。 + +--- + +## 四、合并时最容易忘的三件事 + +1. **新增的 `/api` 路由必须带鉴权**。跑一下 `tests/test_auth.py`—— + 里面有个测试会遍历 `app.routes`,断言除豁免集外每个 `/api` 路由无凭据都返回 401。 + 上游新增接口忘了加鉴权,这个测试会直接失败。 +2. **新增的 WebSocket 路由必须加 `require_ws_auth`**。 + `BaseHTTPMiddleware` 对 WS 完全不生效(`scope["type"] != "http"` 直接放行), + 只靠中间件会漏。同样有测试守着。 +3. **上游若改动 `AsyncFileWriter` 的输出路径规则**,`api/monitor/ingest.py::find_run_files` + 会跟着失效——它靠 glob `{out_dir}/{platform}/jsonl/*_contents_*.jsonl` 定位每轮的产物。 diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..a50bc6e --- /dev/null +++ b/api/auth.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/auth.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Authentication for the WebUI. + +Design constraints that drove this, all verified against the codebase: + +* **Cookies, not bearer headers, are the primary transport.** Browser + WebSockets cannot set custom headers on the handshake, and the data-export + downloads use ``window.open`` (a navigation, also header-less). Only a cookie + is carried on both. The same opaque token is *also* accepted from an + ``Authorization: Bearer`` header so ``curl`` and scripts remain usable. +* **Enforcement is a ``Depends``, not middleware.** ``BaseHTTPMiddleware`` + returns early for any non-``http`` scope, so it never sees a WebSocket -- + a middleware-only gate would leave the live log stream wide open. It is also + overridable per-test via ``app.dependency_overrides``. +* **Sessions are server-side** so logout and password-change revoke immediately. + +Only the environment variable ``MC_PASSWORD`` can bypass the stored hash. That is +the documented way back in if the password is forgotten, which is why it is +never persisted. +""" + +import asyncio +import base64 +import binascii +import hashlib +import hmac +import os +import secrets +import time +from typing import Optional + +from anyio import to_thread +from fastapi import HTTPException, Request, WebSocket, WebSocketException, status +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession + +from tools.time_util import get_current_timestamp + +from .monitor.db import get_session +from .monitor.models import ( + SETTING_AUTH_PASSWORD_HASH, + SETTING_AUTH_PASSWORD_UPDATED_AT, + AuthSession, +) +from .monitor.settings import get_setting, set_setting + +SESSION_COOKIE_NAME = "mc_session" + +# OWASP's current PBKDF2-HMAC-SHA256 guidance. Deliberately slow -- see +# verify_password() for why that cost must not land on the event loop. +PBKDF2_ITERATIONS = 600_000 +PBKDF2_ALGO = "pbkdf2_sha256" + +# A single generic message for every failure mode, so the response never +# reveals whether a password is set, wrong, or empty. +INVALID_CREDENTIALS = "用户名或密码错误" + +# Brute-force throttle. In-process is sufficient: this is a single-user tool and +# uvicorn runs one worker. Documented as reset-on-restart. +THROTTLE_THRESHOLD = 5 +THROTTLE_WINDOW_SECONDS = 900 +THROTTLE_MAX_LOCKOUT_SECONDS = 900 + +_failures: dict[str, list[float]] = {} +_throttle_lock = asyncio.Lock() + + +def _now() -> float: + """Monotonic clock, indirected so tests can drive it without sleeping.""" + return time.monotonic() + + +# --------------------------------------------------------------------------- +# Environment configuration (read at call time so tests can set it per-case) +# --------------------------------------------------------------------------- + +def env_password() -> str: + return os.getenv("MC_PASSWORD", "").strip() + + +def cookie_secure() -> bool: + return os.getenv("MC_COOKIE_SECURE", "").strip().lower() in ("1", "true", "yes", "y") + + +def session_ttl_ms() -> int: + try: + hours = int(os.getenv("MC_SESSION_TTL_HOURS", "336")) + except ValueError: + hours = 336 + return max(hours, 1) * 3_600_000 + + +# --------------------------------------------------------------------------- +# Password hashing +# --------------------------------------------------------------------------- + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def hash_password(password: str, *, iterations: Optional[int] = None) -> str: + """Return a self-describing hash so the iteration count can be raised later + without a migration: ``pbkdf2_sha256$$$``. + + ``iterations`` is resolved at call time (not bound as a default) so tests can + lower it; the production value stays the module constant. + """ + iterations = iterations or PBKDF2_ITERATIONS + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return f"{PBKDF2_ALGO}${iterations}${_b64(salt)}${_b64(digest)}" + + +def _verify_password_sync(password: str, stored: str) -> bool: + try: + algo, iterations_raw, salt_raw, digest_raw = stored.split("$") + if algo != PBKDF2_ALGO: + return False + salt = base64.b64decode(salt_raw) + expected = base64.b64decode(digest_raw) + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations_raw)) + except (ValueError, TypeError, binascii.Error): + return False + return hmac.compare_digest(actual, expected) + + +async def verify_password(password: str, stored: str) -> bool: + """Verify off the event loop. + + At 600k iterations this takes a few hundred milliseconds. Running it inline + in an async handler would block the loop entirely -- stalling the monitor + scheduler and every websocket ping -- and present as "the whole UI freezes + when I click login". + """ + return await to_thread.run_sync(_verify_password_sync, password, stored) + + +async def current_password_hash(session: AsyncSession) -> str: + return (await get_setting(session, SETTING_AUTH_PASSWORD_HASH)) or "" + + +async def set_password(session: AsyncSession, password: str) -> None: + await set_setting(session, SETTING_AUTH_PASSWORD_HASH, hash_password(password)) + await set_setting( + session, SETTING_AUTH_PASSWORD_UPDATED_AT, str(get_current_timestamp()) + ) + + +async def check_password(session: AsyncSession, password: str) -> bool: + """The environment override wins over the stored hash, always. + + That is the escape hatch: forgetting the password is recoverable by setting + MC_PASSWORD and restarting, without touching the database. + """ + override = env_password() + if override: + return hmac.compare_digest(password, override) + + stored = await current_password_hash(session) + if not stored: + return False + return await verify_password(password, stored) + + +async def ensure_initial_credential() -> Optional[str]: + """Seed a password on first run; returns it once so main() can print it. + + Deliberately NOT an unauthenticated "set your password" endpoint: on a + LAN-exposed bind that is a claim-the-instance race where whoever reaches the + page first becomes the administrator. Generating and printing a random + password avoids the race and also avoids locking the operator out. + """ + if env_password(): + return None + + async with get_session() as session: + if await current_password_hash(session): + return None + generated = secrets.token_urlsafe(12) + await set_password(session, generated) + return generated + + +# --------------------------------------------------------------------------- +# Sessions +# --------------------------------------------------------------------------- + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +async def create_session(session: AsyncSession) -> tuple[str, int]: + """Issue a session. Returns (token, expires_at_ms). + + The caller receives the raw token; only its hash is stored. + """ + token = secrets.token_urlsafe(32) + now = get_current_timestamp() + expires_at = now + session_ttl_ms() + session.add( + AuthSession( + token_hash=_hash_token(token), + created_at=now, + expires_at=expires_at, + last_seen_at=now, + ) + ) + return token, expires_at + + +async def resolve_session(session: AsyncSession, token: str) -> Optional[AuthSession]: + if not token: + return None + + row = await session.get(AuthSession, _hash_token(token)) + if row is None: + return None + + now = get_current_timestamp() + if row.expires_at <= now: + await session.delete(row) + return None + + row.last_seen_at = now + return row + + +async def revoke_session(session: AsyncSession, token: str) -> None: + row = await session.get(AuthSession, _hash_token(token)) + if row is not None: + await session.delete(row) + + +async def revoke_all_sessions(session: AsyncSession) -> int: + """Used on password change, which is what makes "all devices logged out" + take effect immediately rather than at token expiry.""" + result = await session.execute(delete(AuthSession)) + return result.rowcount or 0 + + +async def purge_expired_sessions(session: AsyncSession) -> None: + await session.execute(delete(AuthSession).where(AuthSession.expires_at <= get_current_timestamp())) + + +# --------------------------------------------------------------------------- +# Credential extraction and enforcement +# --------------------------------------------------------------------------- + +def token_from_request(request: Request) -> str: + """Cookie first (browsers, websockets, navigations), then Bearer (scripts).""" + token = request.cookies.get(SESSION_COOKIE_NAME, "") + if token: + return token + header = request.headers.get("authorization", "") + if header.lower().startswith("bearer "): + return header[7:].strip() + return "" + + +def _unauthorized() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=INVALID_CREDENTIALS, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def require_auth(request: Request) -> None: + """FastAPI dependency guarding the protected routers. + + Applied per-router via ``include_router(..., dependencies=[Depends(...)])`` + rather than as app-wide middleware, so it appears in the OpenAPI schema, + returns a correct 401, and can be overridden in tests. + """ + token = token_from_request(request) + if not token: + raise _unauthorized() + + async with get_session() as session: + if await resolve_session(session, token) is None: + raise _unauthorized() + + +async def require_ws_auth(websocket: WebSocket) -> None: + """Guard for WebSocket routes. + + These need their own dependency: ``BaseHTTPMiddleware`` passes any non-http + scope straight through, and router-level HTTP dependencies do not apply to + websocket routes. Raising ``WebSocketException`` closes the handshake with + the given code; ``HTTPException`` would be meaningless here. + """ + token = websocket.cookies.get(SESSION_COOKIE_NAME, "") + if not token: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) + + async with get_session() as session: + if await resolve_session(session, token) is None: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) + + +# --------------------------------------------------------------------------- +# Brute-force throttle +# --------------------------------------------------------------------------- + +def client_key(request: Request) -> str: + """Identify the caller for throttling. + + ``X-Forwarded-For`` is only consulted when the operator explicitly opts in, + because otherwise any client could spoof the header and throttle someone + else (or evade its own throttle). + """ + if os.getenv("MC_TRUST_PROXY", "").strip() == "1": + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _recent_failures(key: str) -> list[float]: + cutoff = _now() - THROTTLE_WINDOW_SECONDS + return [ts for ts in _failures.get(key, []) if ts >= cutoff] + + +async def retry_after_seconds(key: str) -> int: + """0 when not throttled, otherwise how long the caller must wait.""" + async with _throttle_lock: + recent = _recent_failures(key) + _failures[key] = recent + if len(recent) < THROTTLE_THRESHOLD: + return 0 + + # Lockout doubles per failure past the threshold, capped. + extra = len(recent) - THROTTLE_THRESHOLD + lockout = min(2 ** extra, THROTTLE_MAX_LOCKOUT_SECONDS) + elapsed = _now() - recent[-1] + remaining = int(lockout - elapsed) + return max(remaining, 1) + + +async def record_failure(key: str) -> None: + async with _throttle_lock: + _failures.setdefault(key, []).append(_now()) + + +async def clear_failures(key: str) -> None: + async with _throttle_lock: + _failures.pop(key, None) + + +def reset_throttle_state() -> None: + """Test hook: drop all throttle state.""" + _failures.clear() diff --git a/api/main.py b/api/main.py index af49b15..87c4111 100644 --- a/api/main.py +++ b/api/main.py @@ -17,7 +17,7 @@ # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 """ -MediaCrawler WebUI API Server +综合采集平台 API Server Start command: uvicorn api.main:app --port 8080 --reload Or: python -m api.main """ @@ -25,28 +25,95 @@ import asyncio import os import sys import subprocess +from contextlib import asynccontextmanager from pathlib import Path -import uvicorn -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse - -from .routers import crawler_router, data_router, websocket_router # Project root directory (used for running subprocesses like uv run main.py) PROJECT_ROOT = Path(__file__).parent.parent +# Load .env before importing anything that reads os.getenv at module import time +# (config/db_config.py does). python-dotenv was already a declared dependency but +# nothing ever called it, so the shipped .env.example had no effect. +from dotenv import load_dotenv + +load_dotenv(PROJECT_ROOT / ".env") + +import uvicorn +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from .auth import ensure_initial_credential, require_auth +from .routers import ( + auth_router, + crawler_router, + data_router, + monitor_router, + settings_router, + websocket_router, +) +from .services.interpreter import describe_interpreter, resolve_python_cmd + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + """Start the monitor scheduler with the server, and shut it down cleanly. + + The scheduler is a background asyncio task, so it must not be tied to a + browser session the way the log broadcaster is -- a scheduled run has to + happen whether or not anyone has the UI open. + """ + from .monitor.db import dispose_engine, init_db + from .monitor.scheduler import monitor_scheduler + + await init_db() + + generated = await ensure_initial_credential() + if generated: + # Printed once, on the run that creates it. There is no unauthenticated + # "set your password" endpoint on purpose: on a LAN bind that would be a + # claim-the-instance race. + rule = "=" * 68 + print( + f"\n{rule}\n" + " WebUI 首次启动,已生成登录密码:\n" + f"\n {generated}\n" + "\n 请立即登录并修改。忘记密码时可设置环境变量 MC_PASSWORD 后重启。\n" + f"{rule}\n", + flush=True, + ) + + await monitor_scheduler.start() + try: + yield + finally: + await monitor_scheduler.stop() + await dispose_engine() + + +# Docs are disabled deliberately: /docs, /redoc and /openapi.json are +# unauthenticated by default, which would hand out a complete map of the API +# (and a "Try it out" console that 401s anyway). app = FastAPI( - title="MediaCrawler WebUI API", - description="API for controlling MediaCrawler from WebUI", - version="1.0.0" + title="综合采集平台 API", + description="API for controlling 综合采集平台 from WebUI", + version="1.0.0", + lifespan=lifespan, + docs_url=None, + redoc_url=None, + openapi_url=None, ) # Get webui static files directory WEBUI_DIR = os.path.join(os.path.dirname(__file__), "webui") -# CORS configuration - allow frontend dev server access +# CORS only matters for a split-origin setup. In production this app serves the +# SPA itself, and in development Vite proxies /api here (see webui/vite.config.ts), +# so the browser always sees a single origin and CORS never actually triggers. +# Kept as an explicit allowlist -- never "*", which is invalid next to +# allow_credentials -- and extensible via env for a dev server reached over LAN. +_extra_origins = [o.strip() for o in os.getenv("MC_CORS_ORIGINS", "").split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=[ @@ -54,15 +121,24 @@ app.add_middleware( "http://localhost:3000", # Backup port "http://127.0.0.1:5173", "http://127.0.0.1:3000", + *_extra_origins, ], + allow_origin_regex=os.getenv("MC_CORS_ORIGIN_REGEX") or None, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# Register routers -app.include_router(crawler_router, prefix="/api") -app.include_router(data_router, prefix="/api") +# Register routers. +# The auth router stays open -- it is the way in. Everything else under /api +# requires a session. Enforcement is a Depends applied per router rather than +# app-wide middleware, because middleware needs a hand-rolled path allowlist and, +# more importantly, never sees WebSocket scopes at all. +app.include_router(auth_router, prefix="/api") +app.include_router(crawler_router, prefix="/api", dependencies=[Depends(require_auth)]) +app.include_router(data_router, prefix="/api", dependencies=[Depends(require_auth)]) +app.include_router(monitor_router, prefix="/api", dependencies=[Depends(require_auth)]) +app.include_router(settings_router, prefix="/api", dependencies=[Depends(require_auth)]) app.include_router(websocket_router, prefix="/api") @@ -73,9 +149,8 @@ async def serve_frontend(): if os.path.exists(index_path): return FileResponse(index_path) return { - "message": "MediaCrawler WebUI API", + "message": "综合采集平台 API", "version": "1.0.0", - "docs": "/docs", "note": "WebUI not found, please build it first: cd webui && npm run build" } @@ -85,18 +160,21 @@ async def health_check(): return {"status": "ok"} -@app.get("/api/env/check") +@app.get("/api/env/check", dependencies=[Depends(require_auth)]) async def check_environment(): - """Check if MediaCrawler environment is configured correctly""" + """Check whether the crawler environment is configured correctly""" try: - # Run uv run main.py --help command to check environment - # Use PROJECT_ROOT so it works regardless of where uvicorn was started + # Run `main.py --help` to check the environment. + # Resolve the interpreter the same way the crawler manager does, so this + # check can never disagree with how main.py is actually executed. + # Use PROJECT_ROOT so it works regardless of where uvicorn was started. + python_cmd = resolve_python_cmd() if sys.platform == "win32": loop = asyncio.get_running_loop() process = await loop.run_in_executor( None, lambda: subprocess.run( - ["uv", "run", "main.py", "--help"], + [*python_cmd, "main.py", "--help"], capture_output=True, timeout=30.0, cwd=str(PROJECT_ROOT) @@ -105,7 +183,7 @@ async def check_environment(): stdout, stderr = process.stdout, process.stderr # bytes else: process = await asyncio.create_subprocess_exec( - "uv", "run", "main.py", "--help", + *python_cmd, "main.py", "--help", stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=str(PROJECT_ROOT) # Project root directory @@ -117,7 +195,8 @@ async def check_environment(): if process.returncode == 0: return { "success": True, - "message": "MediaCrawler environment configured correctly", + "message": "环境配置正确", + "interpreter": describe_interpreter(), "output": stdout.decode("utf-8", errors="ignore")[:500] # Truncate to first 500 characters } else: @@ -136,8 +215,11 @@ async def check_environment(): except FileNotFoundError: return { "success": False, - "message": "uv command not found", - "error": "Please ensure uv is installed and configured in system PATH" + "message": "Python interpreter not found", + "error": ( + "Neither uv nor a usable interpreter was found. Install uv, or create a " + "project virtualenv (.venv) with the requirements installed." + ) } except Exception as e: return { @@ -147,29 +229,29 @@ async def check_environment(): } -@app.get("/api/config/platforms") +@app.get("/api/config/platforms", dependencies=[Depends(require_auth)]) async def get_platforms(): - """Get list of supported platforms""" - return { - "platforms": [ - {"value": "xhs", "label": "Xiaohongshu", "icon": "book-open"}, - {"value": "dy", "label": "Douyin", "icon": "music"}, - {"value": "ks", "label": "Kuaishou", "icon": "video"}, - {"value": "bili", "label": "Bilibili", "icon": "tv"}, - {"value": "wb", "label": "Weibo", "icon": "message-circle"}, - {"value": "tieba", "label": "Baidu Tieba", "icon": "messages-square"}, - {"value": "zhihu", "label": "Zhihu", "icon": "help-circle"}, - ] - } + """Platform capability matrix. + + Returns what each platform's crawler supports (modes, metrics, comment + levels, media) *and* whether the monitoring layer has been wired up for it. + The UI renders its platform switcher and metric columns from this, so the + two are never allowed to drift apart. + """ + from .monitor.platforms import describe_all + + return {"platforms": describe_all()} -@app.get("/api/config/options") +@app.get("/api/config/options", dependencies=[Depends(require_auth)]) async def get_config_options(): """Get all configuration options""" return { "login_types": [ - {"value": "qrcode", "label": "QR Code Login"}, - {"value": "cookie", "label": "Cookie Login"}, + {"value": "qrcode", "label": "扫码登录"}, + # Named for what it now does: the value itself is no longer typed + # here, it is reused from Settings. + {"value": "cookie", "label": "复用已保存的 Cookie"}, ], "crawler_types": [ {"value": "search", "label": "Search Mode"}, @@ -202,4 +284,18 @@ if os.path.exists(WEBUI_DIR): if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8080) + # Loopback by default: the safe choice for anyone who has not thought about + # exposure. Set MC_HOST=0.0.0.0 (e.g. in .env) for LAN access. Before this, + # `python -m api.main` bound 0.0.0.0 while the documented `uvicorn api.main:app` + # bound loopback -- two launch paths with different exposure. + host = os.getenv("MC_HOST", "127.0.0.1") + port = int(os.getenv("MC_PORT", "8080")) + + if host not in ("127.0.0.1", "localhost", "::1"): + print( + f"[综合采集平台] 监听 {host}:{port},局域网内其他机器可访问。\n" + f"[综合采集平台] 已启用密码鉴权;如需暴露到可信网络之外,请走 HTTPS 反向代理。", + flush=True, + ) + + uvicorn.run(app, host=host, port=port) diff --git a/api/monitor/__init__.py b/api/monitor/__init__.py new file mode 100644 index 0000000..be1c291 --- /dev/null +++ b/api/monitor/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/__init__.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Scheduled monitoring layer: repeated crawls with change detection.""" diff --git a/api/monitor/app_settings.py b/api/monitor/app_settings.py new file mode 100644 index 0000000..54ab30e --- /dev/null +++ b/api/monitor/app_settings.py @@ -0,0 +1,395 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/app_settings.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Application settings, declared once and rendered from that declaration. + +Every setting carries a **scope**, which is the whole reason this is not a flat +list: + +* ``platform`` -- each platform keeps its own copy. A cookie obviously differs, + but so do crawl pacing and proxies: what is safe on one platform is a rate + limit on another. Stored as ``platform.

.``. +* ``system`` -- one value for the whole instance. The notification webhook is + a single group chat, and the scheduler has a single active-hours window, so + scoping those per platform would be a fiction. + +The registry is the single source of truth: the API returns it and the Settings +page builds its form from it, so adding a setting does not mean editing a +matching list on the frontend. + +Two rules carry over from how the cookie and webhook were already handled: + +* **Secrets are never returned.** A sensitive key comes back as + ``{present, length, updated_at}``, never as a value. +* **Update is partial.** Only keys present in the request are written, so a form + that does not resubmit a secret cannot silently wipe it. +""" + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from .platforms import PLATFORM_XHS +from .settings import ( + delete_setting, + get_setting, + platform_key, + set_setting, + system_key, +) + +SCOPE_PLATFORM = "platform" +SCOPE_SYSTEM = "system" + +TYPE_BOOL = "bool" +TYPE_INT = "int" +TYPE_STR = "str" +TYPE_SECRET = "secret" + +# Mirrors config/base_config.py. Nothing is written until the operator changes +# something; an unset value simply means "pass no CLI flag, so the config file's +# value applies". +_DEFAULT_SLEEP_SEC = 2 + + +@dataclass +class SettingSpec: + name: str + scope: str + type: str + label: str + help: str = "" + default: Any = None + minimum: Optional[int] = None + maximum: Optional[int] = None + choices: Optional[List[str]] = None + affects_new_runs: bool = True + + def key(self, platform: str = PLATFORM_XHS) -> str: + if self.scope == SCOPE_SYSTEM: + return system_key(self.name) + return platform_key(platform, self.name) + + +SETTING_SPECS: List[SettingSpec] = [ + # --- 平台设置 ----------------------------------------------------------- + SettingSpec( + name="cookie", + scope=SCOPE_PLATFORM, + type=TYPE_SECRET, + label="登录 Cookie", + help="定时监控必须持久化登录态。建议先手动登录一次再粘贴 Cookie。", + ), + SettingSpec( + name="default_interval_minutes", + scope=SCOPE_PLATFORM, + type=TYPE_INT, + label="新任务默认采集间隔(分钟)", + help="仅影响新建任务时的默认值,不会改动已有任务。", + default=360, + minimum=30, + maximum=10080, + ), + SettingSpec( + name="default_max_notes", + scope=SCOPE_PLATFORM, + type=TYPE_INT, + label="默认单轮作品上限", + default=20, + minimum=1, + maximum=500, + ), + SettingSpec( + name="default_max_comments", + scope=SCOPE_PLATFORM, + type=TYPE_INT, + label="默认每篇评论抓取条数", + help="接口无时间排序,只取平台默认排序的前 N 条;N 越大越容易发现新评论。", + default=50, + minimum=1, + maximum=500, + ), + SettingSpec( + name="enable_sub_comments", + scope=SCOPE_PLATFORM, + type=TYPE_BOOL, + label="抓取二级评论", + help="请求量显著增加,风控风险更高。", + default=False, + ), + SettingSpec( + name="crawl_sleep_sec", + scope=SCOPE_PLATFORM, + type=TYPE_INT, + label="请求间隔(秒)", + help="调大更慢但更不容易触发平台限流。各平台风控容忍度不同,故分开配置。", + default=_DEFAULT_SLEEP_SEC, + minimum=0, + maximum=600, + ), + SettingSpec( + name="enable_ip_proxy", + scope=SCOPE_PLATFORM, + type=TYPE_BOOL, + label="启用 IP 代理", + default=False, + ), + SettingSpec( + name="proxy_provider", + scope=SCOPE_PLATFORM, + type=TYPE_STR, + label="代理提供方", + default="kuaidaili", + choices=["kuaidaili", "wandouhttp", "static"], + ), + SettingSpec( + name="proxy_pool_count", + scope=SCOPE_PLATFORM, + type=TYPE_INT, + label="代理 IP 池大小", + default=2, + minimum=1, + maximum=100, + ), + SettingSpec( + name="static_proxy_url", + scope=SCOPE_PLATFORM, + type=TYPE_STR, + label="静态代理地址", + help="仅当提供方选择 static 时使用,格式 http://host:port", + default="", + ), + # --- 系统设置 ----------------------------------------------------------- + SettingSpec( + name="wecom_webhook", + scope=SCOPE_SYSTEM, + type=TYPE_SECRET, + label="企业微信 Webhook", + help="企业微信群机器人地址。所有平台共用同一个群,只有开了推送开关的任务才会发消息。", + ), + SettingSpec( + name="active_hours_start", + scope=SCOPE_SYSTEM, + type=TYPE_INT, + label="活跃时段开始(小时)", + help="只在此时段内触发定时采集。默认 0–23 即全天;支持跨午夜,如 22–6。", + default=0, + minimum=0, + maximum=23, + affects_new_runs=False, + ), + SettingSpec( + name="active_hours_end", + scope=SCOPE_SYSTEM, + type=TYPE_INT, + label="活跃时段结束(小时)", + default=23, + minimum=0, + maximum=23, + affects_new_runs=False, + ), +] + +SPECS_BY_NAME = {spec.name: spec for spec in SETTING_SPECS} + +# Managed by their own endpoints; never writable through the settings API. +# Suffix-matched rather than enumerated, because the cookie bookkeeping keys +# exist once per platform. +_HIDDEN_KEY_SUFFIXES = (".cookie_updated_at", ".cookie_last_ok_at") +_HIDDEN_KEYS = {"auth_password_hash", "auth_password_updated_at"} + + +def _is_hidden(key: str) -> bool: + return key in _HIDDEN_KEYS or key.endswith(_HIDDEN_KEY_SUFFIXES) + + +class SettingValidationError(ValueError): + """Raised for a value the registry will not accept.""" + + +def _coerce(spec: SettingSpec, raw: Any) -> Any: + if spec.type == TYPE_SECRET: + return str(raw) if raw is not None else "" + + if spec.type == TYPE_BOOL: + if isinstance(raw, bool): + return raw + text = str(raw).strip().lower() + if text in ("1", "true", "yes", "y", "on"): + return True + if text in ("0", "false", "no", "n", "off", ""): + return False + raise SettingValidationError(f"{spec.label}: 需要是/否") + + if spec.type == TYPE_INT: + try: + value = int(raw) + except (TypeError, ValueError): + raise SettingValidationError(f"{spec.label}: 需要整数") + if spec.minimum is not None and value < spec.minimum: + raise SettingValidationError(f"{spec.label}: 不能小于 {spec.minimum}") + if spec.maximum is not None and value > spec.maximum: + raise SettingValidationError(f"{spec.label}: 不能大于 {spec.maximum}") + return value + + value = str(raw) if raw is not None else "" + if spec.choices and value not in spec.choices: + raise SettingValidationError(f"{spec.label}: 只能是 {'/'.join(spec.choices)}") + return value + + +def _decode(spec: SettingSpec, raw: Optional[str]) -> Any: + if raw is None: + return spec.default + if spec.type == TYPE_BOOL: + return raw.strip().lower() in ("1", "true", "yes", "y", "on") + if spec.type == TYPE_INT: + try: + return int(raw) + except ValueError: + return spec.default + return raw + + +def _encode(spec: SettingSpec, value: Any) -> str: + if spec.type == TYPE_BOOL: + return "true" if value else "false" + return str(value) + + +def _describe(spec: SettingSpec, platform: str) -> Dict[str, Any]: + return { + "key": spec.key(platform), + "name": spec.name, + "scope": spec.scope, + "type": spec.type, + "label": spec.label, + "help": spec.help, + "default": spec.default, + "minimum": spec.minimum, + "maximum": spec.maximum, + "choices": spec.choices, + "affects_new_runs": spec.affects_new_runs, + } + + +async def get_all(session: AsyncSession, platform: str = PLATFORM_XHS) -> Dict[str, Any]: + """Every editable setting for one platform, plus the system-wide ones. + + Secrets come back masked, never in the clear. + """ + values: Dict[str, Any] = {} + secrets: Dict[str, Any] = {} + + for spec in SETTING_SPECS: + key = spec.key(platform) + raw = await get_setting(session, key) + + if spec.type == TYPE_SECRET: + secrets[key] = {"present": bool(raw), "length": len(raw or "")} + else: + values[key] = _decode(spec, raw) + + return { + "platform": platform, + "values": values, + "secrets": secrets, + "specs": [_describe(spec, platform) for spec in SETTING_SPECS], + } + + +def _spec_for_key(key: str, platform: str) -> Optional[SettingSpec]: + """Resolve a full key back to its spec, rejecting keys for another platform.""" + for spec in SETTING_SPECS: + if spec.key(platform) == key: + return spec + return None + + +async def update( + session: AsyncSession, payload: Dict[str, Any], platform: str = PLATFORM_XHS +) -> List[str]: + """Apply a partial update. Returns the keys that changed. + + Only keys present in ``payload`` are touched: a form that omits a secret must + not blank it. Keys belonging to a different platform are rejected rather than + silently written somewhere unexpected. + """ + changed: List[str] = [] + + for key, raw in payload.items(): + if _is_hidden(key): + continue + + spec = _spec_for_key(key, platform) + if spec is None: + raise SettingValidationError(f"未知的设置项:{key}") + + # An explicit empty string clears a secret -- that is how the UI removes + # one. For everything else it is just a value. + if spec.type == TYPE_SECRET and raw == "": + await delete_setting(session, key) + changed.append(key) + continue + + value = _coerce(spec, raw) + await set_setting(session, key, _encode(spec, value)) + changed.append(key) + + return changed + + +async def get_value( + session: AsyncSession, + name: str, + platform: str = PLATFORM_XHS, + fallback: Any = None, +) -> Any: + """Read one typed setting for internal callers (the runner, the scheduler).""" + spec = SPECS_BY_NAME.get(name) + if spec is None: + return fallback + raw = await get_setting(session, spec.key(platform)) + if raw is None: + return spec.default if fallback is None else fallback + return _decode(spec, raw) + + +async def defaults(session: AsyncSession, platform: str = PLATFORM_XHS) -> Dict[str, Any]: + """Defaults applied when creating a task on this platform. + + This is what makes the Settings page govern new tasks: the create endpoint + falls back to these for anything the caller omits. + """ + return { + "interval_minutes": int( + await get_value(session, "default_interval_minutes", platform, 360) + ), + "max_notes_count": int(await get_value(session, "default_max_notes", platform, 20)), + "max_comments_count": int( + await get_value(session, "default_max_comments", platform, 50) + ), + } + + +async def active_hours(session: AsyncSession) -> tuple[int, int]: + """The (start, end) hour window for scheduled runs. System-wide.""" + start = await get_value(session, "active_hours_start", fallback=0) + end = await get_value(session, "active_hours_end", fallback=23) + return int(start), int(end) diff --git a/api/monitor/db.py b/api/monitor/db.py new file mode 100644 index 0000000..df0ac81 --- /dev/null +++ b/api/monitor/db.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/db.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Database engine for the monitoring layer. + +**MySQL** by default (see ``config/db_config.py`` and ``.env``), with SQLite kept +as an option so the test suite can run without a reachable server. + +Three things here exist because of specific MySQL 5.7 behaviour: + +* **utf8mb4 is forced per table.** This instance's server *and* the target schema + default to ``latin1``; relying on either would mangle or reject Chinese text. + The charset is set on every table rather than on the database, so it holds no + matter what the schema default is. +* **Connections are recycled.** The monitor runs for weeks, and MySQL drops idle + connections after ``wait_timeout`` (8h by default). Without ``pool_recycle`` and + ``pool_pre_ping`` the first query after a quiet night fails with "server has + gone away". +* **The connected schema is asserted at startup.** A misconfigured database name + is caught immediately instead of silently writing to the wrong schema. + +Only the configured schema is ever touched: no ``CREATE DATABASE``, no ``USE``, +no cross-schema query. +""" + +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncIterator, Optional + +from sqlalchemy import event, text +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from .models import MonitorBase + +PROJECT_ROOT = Path(__file__).parent.parent.parent +DATA_DIR = PROJECT_ROOT / "data" +DEFAULT_SQLITE_PATH = DATA_DIR / "monitor.db" + +# Load .env here as well as in api/main.py: this module is imported directly by +# scripts and tests, and a configuration that only applies when the server is the +# entry point is a trap. load_dotenv does not override real environment variables. +from dotenv import load_dotenv + +load_dotenv(PROJECT_ROOT / ".env") + +# Kept identical to config/db_config.py's defaults so one .env drives both the +# monitor database and the crawler's own DB output. +MYSQL_HOST = lambda: os.getenv("MYSQL_DB_HOST", "localhost") # noqa: E731 +MYSQL_PORT = lambda: int(os.getenv("MYSQL_DB_PORT", "3306")) # noqa: E731 +MYSQL_USER = lambda: os.getenv("MYSQL_DB_USER", "root") # noqa: E731 +MYSQL_PWD = lambda: os.getenv("MYSQL_DB_PWD", "") # noqa: E731 +MYSQL_DB_NAME = lambda: os.getenv("MYSQL_DB_NAME", "mediacrawler") # noqa: E731 + +_engine: Optional[AsyncEngine] = None +_session_factory: Optional[async_sessionmaker[AsyncSession]] = None +# None means "resolve from the environment" (MySQL). Tests set a SQLite URL. +_db_url: Optional[str] = None +_expected_schema: Optional[str] = None + + +def resolve_db_url() -> str: + """Build the connection URL. MySQL unless overridden.""" + if _db_url is not None: + return _db_url + + from urllib.parse import quote_plus + + user = quote_plus(MYSQL_USER()) + password = quote_plus(MYSQL_PWD()) + host = MYSQL_HOST() + port = MYSQL_PORT() + name = MYSQL_DB_NAME() + return f"mysql+aiomysql://{user}:{password}@{host}:{port}/{name}?charset=utf8mb4" + + +def is_mysql() -> bool: + return resolve_db_url().startswith("mysql") + + +def set_sqlite_path(path: Path) -> None: + """Point the layer at SQLite. Used by the test suite only.""" + global _db_url, _engine, _session_factory, _expected_schema + _db_url = f"sqlite+aiosqlite:///{Path(path)}" + _engine = None + _session_factory = None + _expected_schema = None + + +def set_db_url(url: str, expected_schema: Optional[str] = None) -> None: + """Point the layer at an explicit URL. ``expected_schema`` enables the guard.""" + global _db_url, _engine, _session_factory, _expected_schema + _db_url = url + _engine = None + _session_factory = None + _expected_schema = expected_schema + + +def expected_schema() -> Optional[str]: + """The schema the connection must be using, if the guard applies.""" + if _expected_schema is not None: + return _expected_schema + return MYSQL_DB_NAME() if is_mysql() else None + + +def get_engine() -> AsyncEngine: + global _engine + if _engine is None: + url = resolve_db_url() + kwargs: dict = {"future": True} + + if url.startswith("mysql"): + # Recycle well inside MySQL's default 8h wait_timeout, and verify a + # pooled connection before handing it out. + kwargs.update(pool_recycle=3600, pool_pre_ping=True, pool_size=5, max_overflow=5) + kwargs["connect_args"] = {"charset": "utf8mb4"} + else: + Path(url.split("///", 1)[-1]).parent.mkdir(parents=True, exist_ok=True) + + _engine = create_async_engine(url, **kwargs) + + if url.startswith("sqlite"): + + @event.listens_for(_engine.sync_engine, "connect") + def _set_sqlite_pragmas(dbapi_connection, _connection_record): # pragma: no cover + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + global _session_factory + if _session_factory is None: + _session_factory = async_sessionmaker( + bind=get_engine(), + class_=AsyncSession, + expire_on_commit=False, + ) + return _session_factory + + +@asynccontextmanager +async def get_session() -> AsyncIterator[AsyncSession]: + """Transactional session. Commits on success, rolls back on error.""" + factory = get_session_factory() + async with factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def _assert_correct_schema(conn) -> None: + """Refuse to run against anything but the configured schema. + + A guard, not the guarantee: the real protection is a MySQL account scoped to + this one schema (see UPSTREAM.md). This catches the ordinary mistake of a + wrong database name in configuration, before a single row is written. + """ + if not is_mysql(): + return + + expected = expected_schema() + if not expected: + return + + current = (await conn.execute(text("SELECT DATABASE()"))).scalar() + if current is None: + raise RuntimeError( + f"数据库连接未选定 schema,期望 {expected!r}。请检查 MYSQL_DB_NAME。" + ) + # lower_case_table_names=1 makes names case-insensitive server-side. + if current.lower() != expected.lower(): + raise RuntimeError( + f"连接的库是 {current!r},但配置要求 {expected!r}。" + f"为避免误写其它库,已拒绝启动。" + ) + print(f"[monitor.db] 已连接 MySQL schema: {current}", flush=True) + + +async def init_db() -> None: + """Create missing tables, then run the small in-place migrations.""" + engine = get_engine() + async with engine.begin() as conn: + await _assert_correct_schema(conn) + await conn.run_sync(MonitorBase.metadata.create_all) + await _ensure_columns(conn) + await _migrate_setting_keys(conn) + + +# Columns added to a table after it may already exist. ``create_all`` only +# creates missing *tables*, so new columns need an explicit ALTER TABLE. +_ADDED_COLUMNS: dict[str, list[tuple[str, str]]] = { + "monitor_task": [ + ("notify_enabled", "BOOLEAN NOT NULL DEFAULT 0"), + ("last_notified_at", "BIGINT NULL"), + ], +} + + +async def _existing_columns(conn, table: str) -> set[str]: + if is_mysql(): + rows = await conn.execute( + text( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t" + ), + {"t": table}, + ) + return {row[0] for row in rows} + + rows = await conn.execute(text(f"PRAGMA table_info({table})")) + return {row[1] for row in rows} + + +async def _ensure_columns(conn) -> None: + for table, columns in _ADDED_COLUMNS.items(): + existing = await _existing_columns(conn, table) + if not existing: + # Table did not exist before this run; create_all built it complete. + continue + for name, ddl in columns: + if name not in existing: + await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")) + + +async def _migrate_setting_keys(conn) -> None: + """Move pre-namespacing setting keys to their scoped names. + + Idempotent: the legacy row is only renamed when the new key is absent, so an + operator's later value is never overwritten. + """ + from .models import LEGACY_SETTING_KEY_RENAMES + + for legacy, scoped in LEGACY_SETTING_KEY_RENAMES.items(): + exists = ( + await conn.execute( + text("SELECT 1 FROM monitor_setting WHERE `key` = :k"), {"k": legacy} + ) + ).first() + if not exists: + continue + + already = ( + await conn.execute( + text("SELECT 1 FROM monitor_setting WHERE `key` = :k"), {"k": scoped} + ) + ).first() + if already: + # Both present: the scoped one is authoritative; drop the stale row. + await conn.execute( + text("DELETE FROM monitor_setting WHERE `key` = :k"), {"k": legacy} + ) + continue + + await conn.execute( + text("UPDATE monitor_setting SET `key` = :new WHERE `key` = :old"), + {"new": scoped, "old": legacy}, + ) + + +async def dispose_engine() -> None: + global _engine, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _session_factory = None diff --git a/api/monitor/ingest.py b/api/monitor/ingest.py new file mode 100644 index 0000000..a49d797 --- /dev/null +++ b/api/monitor/ingest.py @@ -0,0 +1,589 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/ingest.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Turn one run's crawled jsonl into snapshots and change events. + +Pure-ish and offline testable: give it a directory of jsonl files, a run row and +a session, and it does the diffing. No network, no browser. + +Correctness notes that drive the code below: + +* Counts arrive as strings and may be abbreviated ("1.2万", "3亿"). A value that + cannot be parsed is stored as NULL, never 0 -- 0 would forge a large negative + delta on the next comparison. +* The comment endpoint has no time-sort, so only the platform's top-N window is + ever visible. A comment we have not seen before is therefore split into + "posted since last run" vs "seen for the first time", rather than claiming the + former always. +* A bad cookie does not make the crawler exit non-zero; it exits 0 having + fetched nothing. That is detected here as a suspected auth failure. +""" + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from tools.time_util import get_current_timestamp + +from .platforms import PLATFORM_XHS +from .models import ( + EVENT_AUTH_FAILURE, + EVENT_METRIC_DELTA, + EVENT_NEW_COMMENT_POSTED, + EVENT_NEW_COMMENT_SEEN, + EVENT_NEW_NOTE, + EVENT_NO_DATA, + EVENT_RUN_FAILED, + MonitorComment, + MonitorEvent, + MonitorNote, + MonitorNoteMetric, + MonitorRun, + MonitorTask, + RUN_FAILED, + RUN_PARTIAL, + RUN_SUCCESS, +) + +_COUNT_UNITS = { + "": 1, + "万": 10_000, + "w": 10_000, + "W": 10_000, + "k": 1_000, + "K": 1_000, + "亿": 100_000_000, +} +_COUNT_RE = re.compile(r"^([\d.]+)\s*([万wWkK亿]?)$") + +# Metric fields shared by the snapshot table and the delta comparison. +_METRIC_FIELDS = ("liked_count", "comment_count", "collected_count", "share_count") + + +def parse_count(value: Any) -> Optional[int]: + """Parse an XHS interaction count into an int, or None if unintelligible. + + Handles plain numbers, thousands separators, and the Chinese abbreviations + the platform actually returns ("1.2万" -> 12000, "3亿" -> 300000000). + """ + if value is None or isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + + text = str(value).strip().replace(",", "").replace(" ", "") + if not text: + return None + + match = _COUNT_RE.match(text) + if not match: + return None + + try: + number = float(match.group(1)) + except ValueError: + return None + + return int(number * _COUNT_UNITS.get(match.group(2), 1)) + + +# Windows reports hard process failures as NTSTATUS values, which surface in the +# UI as meaningless large integers (e.g. 3221225794 = 0xC0000142). Translating +# the ones we actually see saves the reader a hex-decoding detour. +_WINDOWS_EXIT_REASONS = { + 0xC0000005: "进程访问冲突 (ACCESS_VIOLATION)", + 0xC00000FD: "栈溢出 (STACK_OVERFLOW)", + 0xC000013A: "进程被中断(控制台关闭或 Ctrl+C)", + 0xC0000142: "进程初始化失败 (STATUS_DLL_INIT_FAILED),属启动环境异常,重启服务后重试", + 0xC0000409: "栈缓冲区溢出 (STACK_BUFFER_OVERRUN)", +} + + +def describe_exit_code(code: int) -> str: + """Render an exit code so a human can act on it.""" + unsigned = code & 0xFFFFFFFF if code < 0 else code + reason = _WINDOWS_EXIT_REASONS.get(unsigned) + if reason: + return f"Crawler exited with code {code} (0x{unsigned:08X}): {reason}" + return f"Crawler exited with code {code}" + + +@dataclass +class IngestResult: + status: str + notes_fetched: int = 0 + comments_fetched: int = 0 + new_notes: int = 0 + new_comments: int = 0 + is_baseline: bool = False + error: Optional[str] = None + events: List[str] = field(default_factory=list) + + +def _read_jsonl(path: Path) -> List[Dict[str, Any]]: + """Read a jsonl file, skipping blank or malformed lines.""" + records: List[Dict[str, Any]] = [] + if not path.exists(): + return records + + with path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(item, dict): + records.append(item) + return records + + +def find_run_files( + out_dir: Path, platform: str = PLATFORM_XHS +) -> tuple[List[Path], List[Path]]: + """Locate the contents/comments jsonl files a run produced. + + The crawler writes ``{save_data_path}/{platform}/jsonl/{type}_{item}_{date}.jsonl``. + Glob rather than reconstructing the name: both the crawler type and the date + are runtime-dependent. Returns lists because a crawl crossing midnight + produces one file per day. + """ + jsonl_dir = out_dir / platform / "jsonl" + if not jsonl_dir.is_dir(): + return [], [] + + return ( + sorted(jsonl_dir.glob("*_contents_*.jsonl")), + sorted(jsonl_dir.glob("*_comments_*.jsonl")), + ) + + +async def _emit( + session: AsyncSession, + run: MonitorRun, + event_type: str, + title: str, + *, + severity: str = "info", + target_kind: str = "", + target_id: str = "", + payload: Optional[Dict[str, Any]] = None, +) -> None: + session.add( + MonitorEvent( + task_id=run.task_id, + run_id=run.id, + type=event_type, + severity=severity, + target_kind=target_kind, + target_id=target_id, + title=title, + payload_json=json.dumps(payload or {}, ensure_ascii=False), + created_at=get_current_timestamp(), + ) + ) + + +async def _previous_run_started_at( + session: AsyncSession, task_id: int, run_id: int +) -> Optional[int]: + """Started-at of the most recent earlier successful run, in ms.""" + return await session.scalar( + select(MonitorRun.started_at) + .where( + MonitorRun.task_id == task_id, + MonitorRun.id != run_id, + MonitorRun.status.in_((RUN_SUCCESS, RUN_PARTIAL)), + MonitorRun.started_at.is_not(None), + # Same reasoning as _count_prior_successes: an empty run is a useless + # reference point for "was this comment posted since last time?". + MonitorRun.notes_fetched > 0, + ) + .order_by(MonitorRun.id.desc()) + .limit(1) + ) + + +# How far back to look for proof that the stored login still works. +_AUTH_PROOF_WINDOW_MS = 6 * 60 * 60 * 1000 + + +async def _another_task_succeeded_recently(session: AsyncSession, task_id: int) -> bool: + """Whether a different task fetched data recently, proving the login is valid.""" + since = get_current_timestamp() - _AUTH_PROOF_WINDOW_MS + count = await session.scalar( + select(func.count()) + .select_from(MonitorRun) + .where( + MonitorRun.task_id != task_id, + MonitorRun.status == RUN_SUCCESS, + MonitorRun.started_at.is_not(None), + MonitorRun.started_at >= since, + ) + ) + return bool(count) + + +async def _count_prior_successes(session: AsyncSession, task_id: int, run_id: int) -> int: + return ( + await session.scalar( + select(func.count()) + .select_from(MonitorRun) + .where( + MonitorRun.task_id == task_id, + MonitorRun.id != run_id, + MonitorRun.status.in_((RUN_SUCCESS, RUN_PARTIAL)), + # A run that fetched nothing established no baseline. Without this + # check the first run that actually works after a failed one looks + # like a flood of newly discovered works. + MonitorRun.notes_fetched > 0, + ) + ) + ) or 0 + + +async def _ingest_notes( + session: AsyncSession, + run: MonitorRun, + records: List[Dict[str, Any]], + is_baseline: bool, +) -> int: + """Upsert notes, write metric snapshots, and emit new-note/delta events.""" + now = get_current_timestamp() + new_count = 0 + + for record in records: + note_id = record.get("note_id") + if not note_id: + continue + + note = await session.scalar( + select(MonitorNote).where( + MonitorNote.task_id == run.task_id, + MonitorNote.note_id == note_id, + ) + ) + + title = (record.get("title") or "")[:500] + raw_images = record.get("image_list") or "" + cover = raw_images.split(",")[0] if raw_images else "" + + if note is None: + note = MonitorNote( + task_id=run.task_id, + note_id=note_id, + title=title, + note_url=record.get("note_url") or "", + cover=cover, + creator_hash=record.get("creator_hash") or "", + source_kind=record.get("type") or "", + published_at=_as_int(record.get("time")), + first_seen_run_id=run.id, + first_seen_at=now, + last_seen_run_id=run.id, + last_seen_at=now, + ) + session.add(note) + new_count += 1 + if not is_baseline: + await _emit( + session, + run, + EVENT_NEW_NOTE, + f"新作品:{title or note_id}", + target_kind="note", + target_id=note_id, + payload={"note_id": note_id, "title": title}, + ) + else: + # Only refresh descriptive fields; seen-tracking is updated below. + if title: + note.title = title + note.last_seen_run_id = run.id + note.last_seen_at = now + + await _snapshot_metrics(session, run, note_id, record, now, is_baseline) + + return new_count + + +def _as_int(value: Any) -> Optional[int]: + try: + return int(value) + except (TypeError, ValueError): + return None + + +async def _snapshot_metrics( + session: AsyncSession, + run: MonitorRun, + note_id: str, + record: Dict[str, Any], + now: int, + is_baseline: bool, +) -> None: + """Write this run's metric snapshot and report any change vs the previous one.""" + previous = await session.scalar( + select(MonitorNoteMetric) + .where( + MonitorNoteMetric.task_id == run.task_id, + MonitorNoteMetric.note_id == note_id, + MonitorNoteMetric.run_id != run.id, + ) + .order_by(MonitorNoteMetric.run_id.desc()) + .limit(1) + ) + + parsed = {name: parse_count(record.get(name)) for name in _METRIC_FIELDS} + + session.add( + MonitorNoteMetric( + task_id=run.task_id, + note_id=note_id, + run_id=run.id, + captured_at=now, + liked_count=parsed["liked_count"], + comment_count=parsed["comment_count"], + collected_count=parsed["collected_count"], + share_count=parsed["share_count"], + raw_liked_count=str(record.get("liked_count") or ""), + raw_comment_count=str(record.get("comment_count") or ""), + raw_collected_count=str(record.get("collected_count") or ""), + raw_share_count=str(record.get("share_count") or ""), + ) + ) + + if previous is None or is_baseline: + return + + deltas = {} + for name in _METRIC_FIELDS: + old, new = getattr(previous, name), parsed[name] + # A None on either side means the value was unparseable; skip rather + # than report a bogus change. + if old is None or new is None or old == new: + continue + deltas[name] = {"from": old, "to": new, "delta": new - old} + + if deltas: + summary = "、".join( + f"{_metric_label(name)} {info['from']}→{info['to']}" + for name, info in deltas.items() + ) + await _emit( + session, + run, + EVENT_METRIC_DELTA, + f"互动数据变化:{summary}", + target_kind="note", + target_id=note_id, + payload={"note_id": note_id, "deltas": deltas}, + ) + + +def _metric_label(name: str) -> str: + return { + "liked_count": "点赞", + "comment_count": "评论", + "collected_count": "收藏", + "share_count": "分享", + }.get(name, name) + + +async def _ingest_comments( + session: AsyncSession, + run: MonitorRun, + records: List[Dict[str, Any]], + is_baseline: bool, + previous_run_started_at: Optional[int], +) -> int: + """Upsert comments and emit events for ones never seen before.""" + now = get_current_timestamp() + new_count = 0 + + for record in records: + comment_id = record.get("comment_id") + note_id = record.get("note_id") + if not comment_id or not note_id: + continue + + exists = await session.scalar( + select(MonitorComment.id).where( + MonitorComment.task_id == run.task_id, + MonitorComment.note_id == note_id, + MonitorComment.comment_id == comment_id, + ) + ) + if exists is not None: + continue + + create_time = _as_int(record.get("create_time")) + session.add( + MonitorComment( + task_id=run.task_id, + note_id=note_id, + comment_id=comment_id, + content=(record.get("content") or "")[:2000], + nickname=record.get("nickname") or "", + creator_hash=record.get("creator_hash") or "", + create_time=create_time, + like_count=parse_count(record.get("like_count")), + sub_comment_count=_as_int(record.get("sub_comment_count")) or 0, + parent_comment_id=record.get("parent_comment_id") or "", + first_seen_run_id=run.id, + first_seen_at=now, + ) + ) + new_count += 1 + + if is_baseline: + continue + + # Without a time-sorted comment API we can only observe the top-N window, + # so distinguish a genuinely new comment from one that just surfaced. + posted = ( + create_time is not None + and previous_run_started_at is not None + and create_time > previous_run_started_at + ) + await _emit( + session, + run, + EVENT_NEW_COMMENT_POSTED if posted else EVENT_NEW_COMMENT_SEEN, + f"{'新评论' if posted else '新出现评论'}:{(record.get('content') or '')[:60]}", + target_kind="note", + target_id=note_id, + payload={ + "note_id": note_id, + "comment_id": comment_id, + "create_time": create_time, + "nickname": record.get("nickname") or "", + }, + ) + + return new_count + + +async def ingest_run( + session: AsyncSession, + run: MonitorRun, + task: MonitorTask, + out_dir: Path, +) -> IngestResult: + """Ingest one finished run and return what changed. + + Sets ``run.status``, ``run.is_baseline`` and the counters on the run row. + On a failed or untrustworthy run nothing is diffed -- the "seen" sets only + ever grow, so a partial run must never be allowed to look like deletions. + """ + # A non-zero exit is a genuine crash: trust nothing this run produced. + if run.exit_code not in (0, None): + run.status = RUN_FAILED + run.error_message = describe_exit_code(run.exit_code) + await _emit( + session, + run, + EVENT_RUN_FAILED, + f"采集进程异常退出(code={run.exit_code})", + severity="error", + payload={"exit_code": run.exit_code, "detail": run.error_message}, + ) + return IngestResult(status=RUN_FAILED, error=run.error_message) + + contents_paths, comment_paths = find_run_files(out_dir, task.platform) + contents = [record for path in contents_paths for record in _read_jsonl(path)] + comments = [record for path in comment_paths for record in _read_jsonl(path)] + + run.notes_fetched = len(contents) + run.comments_fetched = len(comments) + + # A bad cookie does NOT fail the process: XHS cookie login is never validated, + # so an unauthenticated session just returns zero notes with exit 0 -- and + # usually does not even create an output file. Treating that as "the creator + # posted nothing" would silently hide login outages, which is exactly what + # monitoring exists to catch. + if not contents: + run.status = RUN_PARTIAL + + # Blaming the cookie is only honest if nothing else is authenticating. + # A sibling task that just succeeded proves the login works, so the + # fault is with this target (bad/expired per-creator token, an empty + # account, or a page-structure change). + if await _another_task_succeeded_recently(session, run.task_id): + run.error_message = ( + "Crawler produced no notes for this target, but other tasks " + "succeeded recently, so the login is probably fine" + ) + await _emit( + session, + run, + EVENT_NO_DATA, + "本次未抓到任何作品:其他任务近期采集正常,登录态应该没问题,请检查该目标是否有效", + severity="warning", + payload={"out_dir": str(out_dir)}, + ) + else: + run.error_message = "Crawler produced no notes; the login cookie may have expired" + await _emit( + session, + run, + EVENT_AUTH_FAILURE, + "疑似登录态失效:本次未抓到任何作品,请检查 Cookie", + severity="error", + payload={"out_dir": str(out_dir)}, + ) + + return IngestResult( + status=RUN_PARTIAL, + error=run.error_message, + comments_fetched=len(comments), + ) + + is_baseline = await _count_prior_successes(session, run.task_id, run.id) == 0 + run.is_baseline = is_baseline + run.status = RUN_SUCCESS + run.error_message = None + + previous_started_at = ( + None if is_baseline else await _previous_run_started_at(session, run.task_id, run.id) + ) + + result = IngestResult( + status=RUN_SUCCESS, + notes_fetched=len(contents), + comments_fetched=len(comments), + is_baseline=is_baseline, + ) + result.new_notes = await _ingest_notes(session, run, contents, is_baseline) + if task.enable_comments: + result.new_comments = await _ingest_comments( + session, run, comments, is_baseline, previous_started_at + ) + + run.new_notes = result.new_notes + run.new_comments = result.new_comments + return result diff --git a/api/monitor/migrate_from_sqlite.py b/api/monitor/migrate_from_sqlite.py new file mode 100644 index 0000000..544cc64 --- /dev/null +++ b/api/monitor/migrate_from_sqlite.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/migrate_from_sqlite.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""One-off: copy the monitoring database from SQLite into MySQL. + + python -m api.monitor.migrate_from_sqlite [--source data/monitor.db] [--dry-run] + +Primary keys are preserved rather than reassigned, because rows in +``monitor_note`` / ``monitor_comment`` / ``monitor_run`` reference ``task_id``; +letting MySQL auto-assign new ids would silently break those links. + +Refuses to run against a target that already holds data unless ``--force`` is +given, so a second accidental run cannot double everything up. +""" + +import argparse +import sqlite3 +import sys +from pathlib import Path +from typing import Any, Dict, List + +PROJECT_ROOT = Path(__file__).parent.parent.parent + +# Insert order matters: monitor_target and monitor_run carry real foreign keys to +# monitor_task, so the parent rows have to land first. +TABLES_IN_ORDER = [ + "monitor_task", + "monitor_target", + "monitor_run", + "monitor_note", + "monitor_note_metric", + "monitor_comment", + "monitor_event", + "monitor_setting", + "auth_session", +] + + +def read_sqlite(path: Path) -> Dict[str, List[Dict[str, Any]]]: + if not path.exists(): + raise SystemExit(f"找不到源库:{path}") + + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + try: + existing = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + data: Dict[str, List[Dict[str, Any]]] = {} + for table in TABLES_IN_ORDER: + if table not in existing: + continue + rows = [dict(row) for row in connection.execute(f"SELECT * FROM {table}")] + if rows: + data[table] = rows + return data + finally: + connection.close() + + +def migrate(source: Path, dry_run: bool, force: bool) -> None: + import pymysql + + from . import db as monitor_db + + data = read_sqlite(source) + if not data: + print("源库里没有可迁移的数据。") + return + + print("源库内容:") + for table, rows in data.items(): + print(f" {table:22} {len(rows)} 行") + + url = monitor_db.resolve_db_url() + if not url.startswith("mysql"): + raise SystemExit(f"目标不是 MySQL:{url}") + + connection = pymysql.connect( + host=monitor_db.MYSQL_HOST(), + port=monitor_db.MYSQL_PORT(), + user=monitor_db.MYSQL_USER(), + password=monitor_db.MYSQL_PWD(), + database=monitor_db.MYSQL_DB_NAME(), + charset="utf8mb4", + autocommit=False, + ) + + try: + with connection.cursor() as cursor: + # Never write outside the configured schema. + cursor.execute("SELECT DATABASE()") + current = cursor.fetchone()[0] + expected = monitor_db.MYSQL_DB_NAME() + if current.lower() != expected.lower(): + raise SystemExit( + f"当前连接的是 {current!r},配置要求 {expected!r};已中止。" + ) + + occupied = [] + for table in data: + cursor.execute(f"SELECT COUNT(*) FROM `{table}`") + if cursor.fetchone()[0]: + occupied.append(table) + + if occupied and not force: + raise SystemExit( + "目标库已有数据:" + ", ".join(occupied) + "\n" + "加 --force 才会继续(会与现有数据并存,造成重复)。" + ) + + if dry_run: + print("\n[试运行] 未写入任何数据。") + return + + total = 0 + for table, rows in data.items(): + columns = list(rows[0].keys()) + column_sql = ", ".join(f"`{c}`" for c in columns) + placeholders = ", ".join(["%s"] * len(columns)) + statement = ( + f"INSERT INTO `{table}` ({column_sql}) VALUES ({placeholders})" + ) + cursor.executemany( + statement, [[row[c] for c in columns] for row in rows] + ) + total += len(rows) + print(f" 已写入 {table:22} {len(rows)} 行") + + connection.commit() + print(f"\n完成,共迁移 {total} 行。") + print("提示:源 SQLite 文件仍在原处,确认无误后自行删除。") + + except Exception: + connection.rollback() + raise + finally: + connection.close() + + +def main(argv: List[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="把监控库从 SQLite 迁到 MySQL") + parser.add_argument( + "--source", + default=str(PROJECT_ROOT / "data" / "monitor.db"), + help="SQLite 源文件路径", + ) + parser.add_argument("--dry-run", action="store_true", help="只检查,不写入") + parser.add_argument( + "--force", action="store_true", help="目标库已有数据时也继续" + ) + args = parser.parse_args(argv) + + migrate(Path(args.source), args.dry_run, args.force) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/monitor/models.py b/api/monitor/models.py new file mode 100644 index 0000000..f3e439d --- /dev/null +++ b/api/monitor/models.py @@ -0,0 +1,367 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/models.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Monitoring layer data model. + +Lives in its own SQLite database (``data/monitor.db``) with its own declarative +Base, deliberately separate from the crawler's ``database/models.py``. The +crawler's DB store overwrites ``liked_count`` and friends in place on every +re-crawl, so it cannot answer "how did this note change?". These tables keep the +history the crawler throws away. + +All timestamps are epoch **milliseconds** (BigInteger), matching the project's +own ``tools.time_util.get_current_timestamp()`` convention. Using ints +throughout avoids naive/aware datetime mixing bugs. +""" + +from typing import Optional + +from sqlalchemy import ( + BigInteger, + Boolean, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class MonitorBase(DeclarativeBase): + """Declarative base for the monitoring database.""" + + +# Run statuses +RUN_PENDING = "pending" +RUN_RUNNING = "running" +RUN_SUCCESS = "success" +RUN_PARTIAL = "partial" +RUN_FAILED = "failed" +RUN_TIMEOUT = "timeout" +RUN_INTERRUPTED = "interrupted" + +# Event types +EVENT_NEW_NOTE = "new_note" +EVENT_NEW_COMMENT_POSTED = "new_comment_posted" +EVENT_NEW_COMMENT_SEEN = "new_comment_seen" +EVENT_METRIC_DELTA = "metric_delta" +EVENT_RUN_FAILED = "run_failed" +EVENT_AUTH_FAILURE = "suspected_auth_failure" +# A run that completed cleanly yet fetched nothing, where the login is provably +# fine because another task just succeeded with it. The target, not the cookie, +# is what needs looking at. +EVENT_NO_DATA = "no_data_found" + +# Task modes. One subprocess handles exactly one crawler type, so a task is +# either creator-driven or note-driven -- never both. +MODE_CREATOR = "creator" +MODE_NOTE = "note" + + +class MonitorTask(MonitorBase): + """One monitored schedule: a set of targets plus an interval.""" + + __tablename__ = "monitor_task" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + platform: Mapped[str] = mapped_column(String(32), nullable=False, default="xhs") + mode: Mapped[str] = mapped_column(String(16), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=360) + + # Crawl window knobs, mirrored onto each run's CLI flags. + max_notes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=20) + enable_comments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + max_comments_count: Mapped[int] = mapped_column(Integer, nullable=False, default=50) + run_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=3600) + + # Push notifications are opt-in per task. A task list that all pushes to one + # webhook turns noisy fast, so silence is the default. + notify_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + # Scheduler state. Persisted so the schedule survives an API restart. + next_run_at: Mapped[Optional[int]] = mapped_column(BigInteger, index=True) + last_run_at: Mapped[Optional[int]] = mapped_column(BigInteger) + last_status: Mapped[str] = mapped_column(String(32), nullable=False, default="idle") + last_error: Mapped[Optional[str]] = mapped_column(Text) + # Lets the UI answer "why did I not get a push for this run?". + last_notified_at: Mapped[Optional[int]] = mapped_column(BigInteger) + + created_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + targets: Mapped[list["MonitorTarget"]] = relationship( + back_populates="task", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class MonitorTarget(MonitorBase): + """One watched creator or note belonging to a task. + + ``external_id`` is the stable identity (XHS user_id / note_id). It is kept + separate from ``xsec_token`` on purpose: tokens expire within weeks, so + treating a tokenised URL as the primary key would make every long-running + task fail eventually. + """ + + __tablename__ = "monitor_target" + __table_args__ = ( + UniqueConstraint("task_id", "kind", "external_id", name="uq_monitor_target"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column( + ForeignKey("monitor_task.id", ondelete="CASCADE"), nullable=False, index=True + ) + kind: Mapped[str] = mapped_column(String(16), nullable=False) + external_id: Mapped[str] = mapped_column(String(128), nullable=False) + xsec_token: Mapped[str] = mapped_column(String(512), nullable=False, default="") + xsec_source: Mapped[str] = mapped_column(String(64), nullable=False, default="") + raw_value: Mapped[str] = mapped_column(Text, nullable=False, default="") + label: Mapped[str] = mapped_column(String(200), nullable=False, default="") + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + task: Mapped["MonitorTask"] = relationship(back_populates="targets") + + +class MonitorRun(MonitorBase): + """One subprocess execution. The run history in the UI is this table.""" + + __tablename__ = "monitor_run" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column( + ForeignKey("monitor_task.id", ondelete="CASCADE"), nullable=False, index=True + ) + trigger: Mapped[str] = mapped_column(String(16), nullable=False, default="scheduled") + status: Mapped[str] = mapped_column(String(16), nullable=False, default=RUN_PENDING, index=True) + phase: Mapped[str] = mapped_column(String(16), nullable=False) + + # Where this run's jsonl landed. Each run gets its own directory because the + # crawler's file writer names output by date only. + save_data_path: Mapped[str] = mapped_column(Text, nullable=False, default="") + + queued_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + not_before: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + started_at: Mapped[Optional[int]] = mapped_column(BigInteger) + finished_at: Mapped[Optional[int]] = mapped_column(BigInteger) + # BigInteger, not Integer: Windows reports failures as unsigned 32-bit + # NTSTATUS values (0xC0000142 = 3221225794), which overflow MySQL's signed + # INT. SQLite's dynamic typing hid this until the data was migrated. + exit_code: Mapped[Optional[int]] = mapped_column(BigInteger) + + notes_fetched: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + comments_fetched: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + new_notes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + new_comments: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # The very first successful run of a task establishes the baseline: every + # note is "new" at that point, so emitting events would be pure noise. + is_baseline: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + # Window actually used, so the UI can be honest that comments are the top N + # in the platform's own ordering rather than a complete set. + max_comments_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + error_message: Mapped[Optional[str]] = mapped_column(Text) + + +class MonitorNote(MonitorBase): + """A note ever seen by a task, plus when it was first/last seen. + + Grain is (task, note) so the same note tracked by two tasks stays independent. + """ + + __tablename__ = "monitor_note" + __table_args__ = ( + UniqueConstraint("task_id", "note_id", name="uq_monitor_note"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column( + ForeignKey("monitor_task.id", ondelete="CASCADE"), nullable=False, index=True + ) + note_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + title: Mapped[str] = mapped_column(Text, nullable=False, default="") + note_url: Mapped[str] = mapped_column(Text, nullable=False, default="") + cover: Mapped[str] = mapped_column(Text, nullable=False, default="") + creator_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="") + published_at: Mapped[Optional[int]] = mapped_column(BigInteger) + + first_seen_run_id: Mapped[Optional[int]] = mapped_column(Integer) + first_seen_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + last_seen_run_id: Mapped[Optional[int]] = mapped_column(Integer) + last_seen_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + +class MonitorNoteMetric(MonitorBase): + """One metric snapshot per (task, note, run) -- the time series. + + Raw strings are kept alongside the parsed integers so a mis-parsed "1.2万" + can always be audited after the fact. + """ + + __tablename__ = "monitor_note_metric" + __table_args__ = ( + UniqueConstraint("task_id", "note_id", "run_id", name="uq_note_metric"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + note_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + run_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + captured_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + # NULL (not 0) when the platform value could not be parsed: storing 0 would + # forge a large negative delta on the next comparison. + liked_count: Mapped[Optional[int]] = mapped_column(Integer) + comment_count: Mapped[Optional[int]] = mapped_column(Integer) + collected_count: Mapped[Optional[int]] = mapped_column(Integer) + share_count: Mapped[Optional[int]] = mapped_column(Integer) + + raw_liked_count: Mapped[str] = mapped_column(String(64), nullable=False, default="") + raw_comment_count: Mapped[str] = mapped_column(String(64), nullable=False, default="") + raw_collected_count: Mapped[str] = mapped_column(String(64), nullable=False, default="") + raw_share_count: Mapped[str] = mapped_column(String(64), nullable=False, default="") + + +class MonitorComment(MonitorBase): + """A comment ever seen by a task. + + The (task, note, comment) uniqueness gives idempotent dedup across runs for + free -- re-running the same crawl cannot double-count. + """ + + __tablename__ = "monitor_comment" + __table_args__ = ( + UniqueConstraint("task_id", "note_id", "comment_id", name="uq_monitor_comment"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + note_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + comment_id: Mapped[str] = mapped_column(String(128), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False, default="") + nickname: Mapped[str] = mapped_column(String(200), nullable=False, default="") + creator_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + # Platform-stated publish time. Used to distinguish a genuinely new comment + # from one that merely entered the visible top-N window this run. + create_time: Mapped[Optional[int]] = mapped_column(BigInteger) + like_count: Mapped[Optional[int]] = mapped_column(Integer) + sub_comment_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + parent_comment_id: Mapped[str] = mapped_column(String(128), nullable=False, default="") + + first_seen_run_id: Mapped[Optional[int]] = mapped_column(Integer) + first_seen_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + +class MonitorEvent(MonitorBase): + """Append-only change feed. This is what the dashboard reads.""" + + __tablename__ = "monitor_event" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + run_id: Mapped[Optional[int]] = mapped_column(Integer, index=True) + type: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info") + target_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="") + target_id: Mapped[str] = mapped_column(String(128), nullable=False, default="") + title: Mapped[str] = mapped_column(Text, nullable=False, default="") + payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + created_at: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + +class MonitorSetting(MonitorBase): + """Key/value store. Holds the XHS cookie for unattended runs.""" + + __tablename__ = "monitor_setting" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str] = mapped_column(Text, nullable=False, default="") + updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + +class AuthSession(MonitorBase): + """A WebUI login session. + + Only the SHA-256 of the token is stored, never the token itself -- a leaked + database therefore does not hand over live sessions. This mirrors the + existing posture of never returning the XHS cookie or webhook value. + + A stateful table (rather than a signed stateless token) is what makes "log + out" and "password changed" take effect immediately. + """ + + __tablename__ = "auth_session" + + token_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + created_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + expires_at: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + last_seen_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + +SETTING_AUTH_PASSWORD_HASH = "auth_password_hash" +SETTING_AUTH_PASSWORD_UPDATED_AT = "auth_password_updated_at" + +# Settings are namespaced by scope: `platform.

.` for values each +# platform keeps its own copy of, `system.` for values shared across all of +# them. Key builders live in settings.py. +SETTING_WECOM_WEBHOOK = "system.wecom_webhook" + +# Pre-namespacing keys, kept only so the startup migration can find and move +# them. Nothing should read these directly. +LEGACY_SETTING_KEY_RENAMES = { + # Pre-batch-2 flat keys. + "xhs_cookie": "platform.xhs.cookie", + "xhs_cookie_updated_at": "platform.xhs.cookie_updated_at", + "xhs_cookie_last_ok_at": "platform.xhs.cookie_last_ok_at", + "wecom_webhook": "system.wecom_webhook", + # Batch-2 keys, before settings gained a scope. Those values belonged to + # Xiaohongshu because it was the only platform, so they migrate to its scope; + # the two scheduling keys were always instance-wide. + "collect.default_interval_minutes": "platform.xhs.default_interval_minutes", + "collect.default_max_notes": "platform.xhs.default_max_notes", + "collect.default_max_comments": "platform.xhs.default_max_comments", + "collect.enable_sub_comments": "platform.xhs.enable_sub_comments", + "collect.crawl_sleep_sec": "platform.xhs.crawl_sleep_sec", + "collect.active_hours_start": "system.active_hours_start", + "collect.active_hours_end": "system.active_hours_end", + "proxy.enable_ip_proxy": "platform.xhs.enable_ip_proxy", + "proxy.provider": "platform.xhs.proxy_provider", + "proxy.pool_count": "platform.xhs.proxy_pool_count", + "proxy.static_proxy_url": "platform.xhs.static_proxy_url", +} + + +# utf8mb4 is forced on every table rather than left to the schema default: this +# deployment's MySQL server *and* the target database both default to latin1, +# which would mangle or reject Chinese text. Setting it per table means it holds +# regardless of what the schema default happens to be. +# +# Must run after every model is declared, hence the end of the module. +for _table in MonitorBase.metadata.tables.values(): + _table.kwargs["mysql_charset"] = "utf8mb4" + _table.kwargs["mysql_collate"] = "utf8mb4_unicode_ci" diff --git a/api/monitor/notify.py b/api/monitor/notify.py new file mode 100644 index 0000000..67d462a --- /dev/null +++ b/api/monitor/notify.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/notify.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Push notifications via a WeCom (企业微信) group robot webhook. + +Two rules shape this module: + +* **One message per run, not per event.** A run that finds twenty new notes must + produce one summary, not twenty pushes. +* **A failed push never fails the crawl.** Notification is best-effort: the run's + data is already committed by the time we get here, so every error is logged + and swallowed. +""" + +import json +from typing import Optional + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from tools.time_util import get_current_timestamp + +from .models import ( + EVENT_AUTH_FAILURE, + EVENT_NEW_NOTE, + EVENT_NO_DATA, + EVENT_RUN_FAILED, + MonitorEvent, + MonitorRun, + MonitorTask, +) +from .settings import get_setting + +# Short on purpose: the scheduler awaits the run, so a hanging webhook would +# stall every other task behind it. +WEBHOOK_TIMEOUT_SECONDS = 10.0 + +# Only these event types are worth interrupting someone for. NO_DATA is included +# because a run that fetched nothing at all is always anomalous -- a creator +# always has *some* notes -- even when the login is not the culprit. +NOTIFIABLE_EVENT_TYPES = ( + EVENT_AUTH_FAILURE, + EVENT_RUN_FAILED, + EVENT_NO_DATA, + EVENT_NEW_NOTE, +) + +# WeCom markdown is a limited subset; coloured text is the one bit of flair it +# supports and it makes failures stand out in a busy group chat. +_COLOR_WARNING = "warning" +_COLOR_INFO = "info" + + +async def send_wecom(webhook_url: str, content: str) -> tuple[bool, str]: + """Post a markdown message to a WeCom group robot. + + Returns (ok, detail) rather than raising, so callers can surface the reason + in the UI when the user clicks "send test". + """ + if not webhook_url: + return False, "Webhook 未配置" + + payload = {"msgtype": "markdown", "markdown": {"content": content}} + + try: + async with httpx.AsyncClient(timeout=WEBHOOK_TIMEOUT_SECONDS) as client: + response = await client.post(webhook_url, json=payload) + response.raise_for_status() + body = response.json() + except httpx.HTTPError as exc: + return False, f"请求失败:{exc}" + except json.JSONDecodeError: + return False, "返回内容不是合法 JSON,请检查 Webhook 地址" + + # WeCom answers 200 with a non-zero errcode on failure. + errcode = body.get("errcode") + if errcode != 0: + return False, f"企业微信返回 errcode={errcode} {body.get('errmsg', '')}" + + return True, "发送成功" + + +async def get_webhook_url(session: AsyncSession) -> str: + from .models import SETTING_WECOM_WEBHOOK + + return (await get_setting(session, SETTING_WECOM_WEBHOOK)) or "" + + +async def build_run_message( + session: AsyncSession, + task: MonitorTask, + run: MonitorRun, +) -> Optional[str]: + """Compose one markdown summary for a finished run, or None if nothing to say.""" + events = list( + ( + await session.scalars( + select(MonitorEvent) + .where( + MonitorEvent.run_id == run.id, + MonitorEvent.type.in_(NOTIFIABLE_EVENT_TYPES), + ) + .order_by(MonitorEvent.id) + ) + ).all() + ) + if not events: + return None + + failures = [ + e for e in events if e.type in (EVENT_AUTH_FAILURE, EVENT_RUN_FAILED, EVENT_NO_DATA) + ] + new_notes = [e for e in events if e.type == EVENT_NEW_NOTE] + + lines: list[str] = [] + + if failures: + # Word the header from what actually happened, not from whether new notes + # accompanied it: a login outage usually brings no new notes either. + unavailable = any(e.type == EVENT_NO_DATA for e in failures) and not any( + e.type in (EVENT_AUTH_FAILURE, EVENT_RUN_FAILED) for e in failures + ) + header = "监控任务未抓到数据" if unavailable else "监控任务异常" + lines.append(f"**⚠️ {header}:{task.name}**") + for event in failures: + lines.append(f'> {event.title}') + else: + lines.append(f"**📢 监控任务有新作品:{task.name}**") + + if new_notes: + lines.append(f"> 新增作品 **{len(new_notes)}** 篇") + # Cap the listing: a first-ever run or a long gap can produce a lot, and + # a wall of text is worse than a count. + for event in new_notes[:10]: + payload = _load_payload(event.payload_json) + title = payload.get("title") or event.target_id + note_id = payload.get("note_id") or event.target_id + url = f"https://www.xiaohongshu.com/explore/{note_id}" + lines.append(f"> [{title}]({url})") + if len(new_notes) > 10: + lines.append(f"> …等共 {len(new_notes)} 篇") + + if run.is_baseline: + lines.append("> (首轮基线,未计入新增统计)") + + return "\n".join(lines) + + +def _load_payload(raw: str) -> dict: + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +async def notify_run(session: AsyncSession, task: MonitorTask, run: MonitorRun) -> Optional[str]: + """Push a summary for a finished run if the task opted in. + + Returns the message that was sent, or None. Never raises. + """ + try: + if not task.notify_enabled: + return None + + webhook_url = await get_webhook_url(session) + if not webhook_url: + return None + + message = await build_run_message(session, task, run) + if not message: + return None + + ok, detail = await send_wecom(webhook_url, message) + if not ok: + print(f"[monitor.notify] task {task.id} push failed: {detail}") + return None + + task.last_notified_at = get_current_timestamp() + return message + + except Exception as exc: # pragma: no cover - notification must never break a run + print(f"[monitor.notify] unexpected error: {exc}") + return None diff --git a/api/monitor/platforms.py b/api/monitor/platforms.py new file mode 100644 index 0000000..e605a50 --- /dev/null +++ b/api/monitor/platforms.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/platforms.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Platform capability matrix. + +The single source of truth for what each platform can do. The UI renders its +platform switcher and metric columns from this, and the API validates against +it. + +Two distinct things are recorded here, and conflating them would be misleading: + +* ``crawler_modes`` / ``metrics`` / ``comment_levels`` / ``media`` describe what + the upstream crawler module actually supports. These were read out of the + platform modules, not assumed -- all seven implement search/detail/creator; + the real differences are in which interaction metrics they capture. +* ``monitor_wired`` says whether the *monitoring layer* has been hooked up. It + currently covers only Xiaohongshu: ``runner.py`` pins the platform, + ``ingest.py`` reads a fixed ``xhs/jsonl`` directory, and ``service.py`` only + parses Xiaohongshu target URLs. + +A platform can therefore be fully crawlable by upstream and still not usable for +monitoring, which is exactly the state of the other six today. +""" + +from typing import Any, Dict, List, Optional + +PLATFORM_XHS = "xhs" + +PLATFORM_LABELS = { + "xhs": "小红书", + "dy": "抖音", + "ks": "快手", + "bili": "B站", + "wb": "微博", + "tieba": "贴吧", + "zhihu": "知乎", +} + +# Interaction metrics each platform's store actually persists. Xiaohongshu has no +# play count or danmaku; Bilibili has both and the widest set; Kuaishou carries +# no comment/share/collect at all; Tieba stores only reply counts. +PLATFORM_CAPABILITIES: Dict[str, Dict[str, Any]] = { + "xhs": { + "crawler_modes": ["search", "detail", "creator"], + "metrics": ["liked_count", "comment_count", "collected_count", "share_count"], + "comment_levels": 2, + "media": True, + "monitor_wired": True, + }, + "dy": { + "crawler_modes": ["search", "detail", "creator"], + "metrics": ["liked_count", "comment_count", "collected_count", "share_count"], + "comment_levels": 2, + "media": True, + "monitor_wired": False, + }, + "ks": { + "crawler_modes": ["search", "detail", "creator"], + # No comment/share/collect in the Kuaishou store; sub-comments are stored + # flat with no parent link and carry no like count. + "metrics": ["liked_count", "view_count"], + "comment_levels": 1, + "media": True, + "monitor_wired": False, + }, + "bili": { + "crawler_modes": ["search", "detail", "creator"], + "metrics": [ + "liked_count", + "video_play_count", + "video_danmaku", + "comment_count", + "video_favorite_count", + "video_coin_count", + "video_share_count", + ], + "comment_levels": 2, + "media": True, + "monitor_wired": False, + }, + "wb": { + "crawler_modes": ["search", "detail", "creator"], + # Weibo has no collect count, and its comment count field is named + # differently in the model. + "metrics": ["liked_count", "comments_count", "shared_count"], + "comment_levels": 2, + "media": True, + "monitor_wired": False, + }, + "tieba": { + "crawler_modes": ["search", "detail", "creator"], + "metrics": ["total_replay_num", "total_replay_page"], + "comment_levels": 2, + "media": False, + "monitor_wired": False, + }, + "zhihu": { + "crawler_modes": ["search", "detail", "creator"], + "metrics": ["voteup_count", "comment_count"], + "comment_levels": 2, + "media": False, + "monitor_wired": False, + }, +} + +METRIC_LABELS = { + "liked_count": "点赞", + "comment_count": "评论", + "collected_count": "收藏", + "share_count": "分享", + "view_count": "播放", + "video_play_count": "播放", + "video_danmaku": "弹幕", + "video_favorite_count": "收藏", + "video_coin_count": "投币", + "video_share_count": "分享", + "comments_count": "评论", + "shared_count": "转发", + "total_replay_num": "回复数", + "total_replay_page": "回复页数", + "voteup_count": "赞同", +} + +# Monitoring modes, mapped to the CLI crawler types upstream understands. +MONITOR_MODE_CREATOR = "creator" +MONITOR_MODE_NOTE = "note" +CLI_TYPE_FOR_MODE = { + MONITOR_MODE_CREATOR: "creator", + MONITOR_MODE_NOTE: "detail", +} + + +class UnsupportedPlatformError(ValueError): + """Raised for an unknown platform, or one the monitor layer cannot run.""" + + +def all_platforms() -> List[str]: + return list(PLATFORM_CAPABILITIES) + + +def is_known(platform: str) -> bool: + return platform in PLATFORM_CAPABILITIES + + +def is_monitor_wired(platform: str) -> bool: + return bool(PLATFORM_CAPABILITIES.get(platform, {}).get("monitor_wired")) + + +def describe(platform: str) -> Optional[Dict[str, Any]]: + capability = PLATFORM_CAPABILITIES.get(platform) + if capability is None: + return None + return { + "value": platform, + "label": PLATFORM_LABELS.get(platform, platform), + **capability, + "metric_labels": { + metric: METRIC_LABELS.get(metric, metric) for metric in capability["metrics"] + }, + } + + +def describe_all() -> List[Dict[str, Any]]: + return [describe(platform) for platform in all_platforms()] + + +def ensure_runnable(platform: str) -> None: + """Validate a platform for a monitoring task. + + An unwired platform is rejected outright rather than accepted and left to + silently produce nothing -- the same silent-failure shape that made a valid + creator look like an expired login earlier. + """ + if not is_known(platform): + raise UnsupportedPlatformError( + f"未知平台:{platform}(支持:{', '.join(all_platforms())})" + ) + if not is_monitor_wired(platform): + label = PLATFORM_LABELS.get(platform, platform) + raise UnsupportedPlatformError( + f"{label}的爬虫已支持,但监控层尚未接通,暂时无法创建监控任务。" + ) diff --git a/api/monitor/report.py b/api/monitor/report.py new file mode 100644 index 0000000..7467a2b --- /dev/null +++ b/api/monitor/report.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/report.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Cross-task reporting: what grew, and what is new, over a date range. + +Two families of numbers that answer different questions and are therefore kept +as separate columns: + +* **互动增量** — Σ(current − previous) across the selected notes. "How many likes + did this set of notes gain?" +* **新增内容** — count of newly discovered notes and comments. "How much new + material showed up?" + +The per-day interaction delta is defined as *last value on the day* minus *last +value before the day* (0 when the note was first seen on that day). That keeps +growth from a note's first observation counted once, rather than smeared across +every later day. + +Aggregation runs in Python over the snapshots rather than as one large SQL +query: the per-note-per-day baseline lookup is a windowed operation that SQLite +expresses awkwardly, and the row counts here are small enough that clarity is +worth more than the query planner. +""" + +from bisect import bisect_right +from datetime import date, datetime, time, timedelta +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .models import MonitorComment, MonitorNote, MonitorNoteMetric + +METRIC_FIELDS = ("liked_count", "comment_count", "collected_count", "share_count") + +METRIC_LABELS = { + "liked_count": "点赞", + "comment_count": "评论", + "collected_count": "收藏", + "share_count": "分享", +} + + +def day_bounds(day: date) -> tuple[int, int]: + """Inclusive epoch-millisecond bounds for a local calendar day.""" + start = datetime.combine(day, time.min) + end = datetime.combine(day, time.max) + return int(start.timestamp() * 1000), int(end.timestamp() * 1000) + + +def iter_days(start: date, end: date) -> List[date]: + days = [] + cursor = start + while cursor <= end: + days.append(cursor) + cursor += timedelta(days=1) + return days + + +def compute_daily_rows( + series_by_note: Dict[str, List[tuple[int, Dict[str, Optional[int]]]]], + notes_per_day: Dict[date, int], + comments_per_day: Dict[date, int], + days: Sequence[date], +) -> List[Dict[str, Any]]: + """Pure aggregation. ``series_by_note`` must be sorted by timestamp ascending.""" + prepared = {note_id: ([ts for ts, _ in points], points) for note_id, points in series_by_note.items()} + + rows: List[Dict[str, Any]] = [] + for day in days: + day_start, day_end = day_bounds(day) + totals = {field: 0 for field in METRIC_FIELDS} + # Records *which* metric could not be compared, not just that something + # could not. A blanket flag loses all value the moment one permanently + # unparseable field makes every row "incomplete". + partial_metrics: set[str] = set() + + for times, points in prepared.values(): + end_index = bisect_right(times, day_end) - 1 + if end_index < 0: + # Not yet tracked on this day. + continue + + end_values = points[end_index][1] + start_index = bisect_right(times, day_start - 1) - 1 + # No earlier snapshot means the note first appeared in this window, + # so it starts from zero -- all of its count is genuinely new. + start_values = ( + points[start_index][1] if start_index >= 0 else {f: 0 for f in METRIC_FIELDS} + ) + + for field in METRIC_FIELDS: + end_value, start_value = end_values.get(field), start_values.get(field) + if end_value is None or start_value is None: + # An unparseable count on either side makes the delta unknown; + # skipping beats reporting a fabricated number. + partial_metrics.add(field) + continue + totals[field] += end_value - start_value + + row: Dict[str, Any] = { + "date": day.isoformat(), + "new_notes": notes_per_day.get(day, 0), + "new_comments": comments_per_day.get(day, 0), + "partial_metrics": sorted(partial_metrics), + } + row.update({f"{field}_delta": value for field, value in totals.items()}) + rows.append(row) + + return rows + + +async def build_report( + session: AsyncSession, + task_ids: Optional[Iterable[int]], + start_day: date, + end_day: date, +) -> Dict[str, Any]: + """Daily rows plus totals for the selected tasks over the given date range.""" + start_ms, _ = day_bounds(start_day) + _, end_ms = day_bounds(end_day) + + scope = list(task_ids) if task_ids else None + days = iter_days(start_day, end_day) + + # Fetch every snapshot up to the range end: the delta on the first day needs + # the last value from *before* the range, so a lower bound would be wrong. + metric_stmt = select(MonitorNoteMetric).where(MonitorNoteMetric.captured_at <= end_ms) + if scope is not None: + metric_stmt = metric_stmt.where(MonitorNoteMetric.task_id.in_(scope)) + metric_stmt = metric_stmt.order_by(MonitorNoteMetric.note_id, MonitorNoteMetric.run_id) + + series_by_note: Dict[str, List[tuple[int, Dict[str, Optional[int]]]]] = {} + included_note_ids: set[str] = set() + for snapshot in (await session.scalars(metric_stmt)).all(): + included_note_ids.add(snapshot.note_id) + series_by_note.setdefault(snapshot.note_id, []).append( + ( + snapshot.captured_at, + {field: getattr(snapshot, field) for field in METRIC_FIELDS}, + ) + ) + + note_stmt = select(MonitorNote.first_seen_at).where( + MonitorNote.first_seen_at >= start_ms, MonitorNote.first_seen_at <= end_ms + ) + if scope is not None: + note_stmt = note_stmt.where(MonitorNote.task_id.in_(scope)) + + comment_stmt = select(MonitorComment.first_seen_at).where( + MonitorComment.first_seen_at >= start_ms, MonitorComment.first_seen_at <= end_ms + ) + if scope is not None: + comment_stmt = comment_stmt.where(MonitorComment.task_id.in_(scope)) + + notes_per_day = _count_by_day((await session.scalars(note_stmt)).all()) + comments_per_day = _count_by_day((await session.scalars(comment_stmt)).all()) + + rows = compute_daily_rows(series_by_note, notes_per_day, comments_per_day, days) + + totals = { + "new_notes": sum(row["new_notes"] for row in rows), + "new_comments": sum(row["new_comments"] for row in rows), + } + for field in METRIC_FIELDS: + totals[f"{field}_delta"] = sum(row[f"{field}_delta"] for row in rows) + + return { + "start_date": start_day.isoformat(), + "end_date": end_day.isoformat(), + "task_ids": scope, + "rows": rows, + "totals": totals, + "note_count": len(included_note_ids), + "has_partial_data": any(row["partial_metrics"] for row in rows), + "partial_metrics": sorted({field for row in rows for field in row["partial_metrics"]}), + "metric_labels": METRIC_LABELS, + } + + +def _count_by_day(timestamps: Iterable[Optional[int]]) -> Dict[date, int]: + counts: Dict[date, int] = {} + for ts in timestamps: + if ts is None: + continue + day = datetime.fromtimestamp(ts / 1000).date() + counts[day] = counts.get(day, 0) + 1 + return counts diff --git a/api/monitor/runner.py b/api/monitor/runner.py new file mode 100644 index 0000000..d0895aa --- /dev/null +++ b/api/monitor/runner.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/runner.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Execute a single monitoring run: build the command, wait, then ingest. + +Runs reuse ``CrawlerManager`` so that monitor crawls share the existing +single-subprocess guarantee and their logs stream to the existing Terminal +component over the existing log WebSocket. +""" + +import asyncio +import os +from pathlib import Path +from typing import Iterable, List, Optional + +from tools.time_util import get_current_timestamp + +from ..schemas import ( + CrawlerStartRequest, + CrawlerTypeEnum, + LoginTypeEnum, + PlatformEnum, + SaveDataOptionEnum, +) +from ..services import crawler_manager +from . import app_settings, notify +from .db import get_session +from .ingest import IngestResult, ingest_run +from .models import ( + MODE_CREATOR, + RUN_FAILED, + RUN_PENDING, + RUN_RUNNING, + RUN_TIMEOUT, + MonitorRun, + MonitorTarget, + MonitorTask, +) +from .settings import get_cookie, mark_cookie_ok + +PROJECT_ROOT = Path(__file__).parent.parent.parent +MONITOR_RUNS_DIR = PROJECT_ROOT / "data" / "monitor_runs" + +# Monitor platform ids align with PlatformEnum's values, but mapping explicitly +# beats relying on that coincidence. +_PLATFORM_ENUM = { + "xhs": PlatformEnum.XHS, + "dy": PlatformEnum.DOUYIN, + "ks": PlatformEnum.KUAISHOU, + "bili": PlatformEnum.BILIBILI, + "wb": PlatformEnum.WEIBO, + "tieba": PlatformEnum.TIEBA, + "zhihu": PlatformEnum.ZHIHU, +} + +_XHS_WEB_BASE = "https://www.xiaohongshu.com" +_CREATOR_PATH = "/user/profile" +_NOTE_PATH = "/explore" + +# Timeout used when the caller does not care; tasks carry their own. +DEFAULT_RUN_TIMEOUT_SECONDS = 3600 + + +def build_target_url(value: str, kind: str) -> str: + """Turn a stored target into a URL the crawler's parser accepts. + + Always emits a full URL rather than a bare id: the XHS parser accepts a bare + 24-hex id only, so the URL form is the safer universal input. The + ``xsec_token`` is appended when present but is deliberately optional -- it + expires, and the id alone is what keeps a long-running task alive. + """ + path = _CREATOR_PATH if kind == MODE_CREATOR else _NOTE_PATH + return f"{_XHS_WEB_BASE}{path}/{value}" + + +def build_target_urls(mode: str, targets: Iterable[MonitorTarget]) -> List[str]: + urls = [] + for target in targets: + url = build_target_url(target.external_id, target.kind) + if target.xsec_token: + url = f"{url}?xsec_token={target.xsec_token}" + if target.xsec_source: + url = f"{url}&xsec_source={target.xsec_source}" + urls.append(url) + return urls + + +async def _strategy_settings(session, platform: str) -> dict: + """Crawl-strategy and proxy settings for one platform. + + Per-platform because the values genuinely differ: what is a safe request + interval on one site is a rate limit on another. Read per run rather than + cached, so a change takes effect on the next scheduled run. + """ + return { + "enable_sub_comments": bool( + await app_settings.get_value(session, "enable_sub_comments", platform, False) + ), + "crawl_sleep_sec": int( + await app_settings.get_value(session, "crawl_sleep_sec", platform, 2) + ), + "enable_ip_proxy": bool( + await app_settings.get_value(session, "enable_ip_proxy", platform, False) + ), + "proxy_provider": await app_settings.get_value( + session, "proxy_provider", platform, "kuaidaili" + ), + "proxy_pool_count": int( + await app_settings.get_value(session, "proxy_pool_count", platform, 2) + ), + "static_proxy_url": await app_settings.get_value( + session, "static_proxy_url", platform, "" + ), + } + + +def _write_cookie_file(path: Path, cookie: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(cookie, encoding="utf-8") + + +def _remove_cookie_file(path: Path) -> None: + """Best-effort removal; the cookie is a credential, do not leave it around.""" + try: + os.remove(path) + except OSError: + pass + + +async def execute_task(task_id: int, trigger: str = "manual") -> IngestResult: + """Run one monitoring cycle for ``task_id`` and ingest its output. + + Split into three phases with separate short-lived DB sessions so no + transaction is held open across the multi-minute subprocess run. + """ + # --- Phase 1: book the run and work out where its output goes ------------- + async with get_session() as session: + task = await session.get(MonitorTask, task_id) + if task is None: + raise ValueError(f"Monitor task {task_id} not found") + + targets = [target for target in task.targets if target.enabled] + if not targets: + raise ValueError(f"Monitor task {task_id} has no enabled targets") + + platform = task.platform + urls = build_target_urls(task.mode, targets) + cookie = await get_cookie(session, platform) + strategy = await _strategy_settings(session, platform) + + run = MonitorRun( + task_id=task.id, + trigger=trigger, + status=RUN_PENDING, + phase=task.mode, + save_data_path="", + queued_at=get_current_timestamp(), + not_before=0, + max_comments_count=task.max_comments_count if task.enable_comments else 0, + ) + session.add(run) + await session.flush() + + run_id = run.id + out_dir = MONITOR_RUNS_DIR / str(task.id) / str(run_id) + run.save_data_path = str(out_dir) + + # Snapshot the values the subprocess needs; `task` is detached after commit. + mode = task.mode + enable_comments = task.enable_comments + max_notes_count = task.max_notes_count + max_comments_count = task.max_comments_count + timeout_seconds = task.run_timeout_seconds + + # --- Phase 2: run the crawler outside any transaction --------------------- + cookie_file = out_dir / ".cookies" + _write_cookie_file(cookie_file, cookie) + + request = CrawlerStartRequest( + platform=_PLATFORM_ENUM[platform], + login_type=LoginTypeEnum.COOKIE, + crawler_type=CrawlerTypeEnum.CREATOR if mode == MODE_CREATOR else CrawlerTypeEnum.DETAIL, + creator_ids=",".join(urls) if mode == MODE_CREATOR else "", + specified_ids=",".join(urls) if mode != MODE_CREATOR else "", + start_page=1, + enable_comments=enable_comments, + enable_sub_comments=strategy["enable_sub_comments"], + enable_media=False, + save_option=SaveDataOptionEnum.JSONL, + cookies="", + headless=True, + max_notes_count=max_notes_count, + max_comments_count=max_comments_count, + # Isolate this run's output: the crawler names files by date only, so + # otherwise same-day runs would append into one shared file. + save_data_path=str(out_dir), + # Unattended runs must not try to attach to the user's desktop Chrome. + enable_cdp_mode=False, + # Only injecting web_session is not enough to sign requests from a cold + # browser profile. + inject_all_cookies=True, + save_login_state=True, + cookies_file=str(cookie_file), + max_concurrency_num=1, + # Strategy + proxy, surfaced on the Settings page. + crawler_max_sleep_sec=strategy["crawl_sleep_sec"], + enable_ip_proxy=strategy["enable_ip_proxy"], + ip_proxy_pool_count=strategy["proxy_pool_count"], + ip_proxy_provider_name=strategy["proxy_provider"], + static_proxy_url=strategy["static_proxy_url"] or None, + ) + + async with get_session() as session: + run = await session.get(MonitorRun, run_id) + if run is not None: + run.status = RUN_RUNNING + run.started_at = get_current_timestamp() + + try: + exit_code = await crawler_manager.run_and_wait(request, timeout=timeout_seconds) + finally: + _remove_cookie_file(cookie_file) + + # --- Phase 3: ingest ------------------------------------------------------ + async with get_session() as session: + run = await session.get(MonitorRun, run_id) + task = await session.get(MonitorTask, task_id) + if run is None or task is None: + raise ValueError(f"Run {run_id} or task {task_id} vanished during execution") + + if exit_code == -1 and not (out_dir / "xhs").exists(): + # run_and_wait returns -1 when the process could not start or timed out. + run.status = RUN_TIMEOUT + run.finished_at = get_current_timestamp() + run.exit_code = exit_code + run.error_message = "Run was killed by timeout or failed to start" + result = IngestResult(status=RUN_TIMEOUT, error=run.error_message) + else: + run.exit_code = exit_code + run.finished_at = get_current_timestamp() + result = await ingest_run(session, run, task, out_dir) + + # A run that authenticated fine is the only useful signal that the + # stored cookie still works. + if result.notes_fetched > 0: + await mark_cookie_ok(session, task.platform) + + task.last_run_at = run.finished_at + task.last_status = result.status + task.last_error = result.error + + # --- Phase 4: notify ------------------------------------------------------ + # Runs after the ingest transaction has committed, in its own session. A push + # failure must never roll back collected data, and notify_run() swallows its + # own errors for the same reason. + async with get_session() as session: + task = await session.get(MonitorTask, task_id) + run = await session.get(MonitorRun, run_id) + if task is not None and run is not None: + await notify.notify_run(session, task, run) + + return result diff --git a/api/monitor/scheduler.py b/api/monitor/scheduler.py new file mode 100644 index 0000000..0792f1a --- /dev/null +++ b/api/monitor/scheduler.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/scheduler.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Background scheduler for monitor tasks. + +One asyncio loop polls for due tasks and hands them to the runner. A plain loop +is enough here: there is exactly one process, one global crawler subprocess, and +therefore no concurrency to coordinate -- a cron-style library would add a +dependency without adding a capability. + +Scheduling is **fixed-delay**, not fixed-rate: ``next_run_at`` is set from the +moment a run starts, so a slow run cannot make its task fire back-to-back. +""" + +import asyncio +import random +from datetime import datetime +from typing import Optional + +from sqlalchemy import select + +from tools.time_util import get_current_timestamp + +from ..services import crawler_manager +from . import app_settings +from .db import get_session +from .models import MonitorRun, MonitorTask, RUN_INTERRUPTED, RUN_RUNNING +from .runner import execute_task +from .settings import get_cookie + +POLL_INTERVAL_SECONDS = 20 +# Spread tasks sharing an interval so they do not all come due on the same tick. +JITTER_SECONDS = 60 + +_MS_PER_MINUTE = 60_000 + + +class MonitorScheduler: + """Polls the task table and runs whatever is due.""" + + def __init__(self) -> None: + self._loop_task: Optional[asyncio.Task] = None + self._stopping = asyncio.Event() + # Avoids logging "no cookie" on every single tick. + self._warned_no_cookie = False + + async def start(self) -> None: + if self._loop_task is not None and not self._loop_task.done(): + return + self._stopping.clear() + self._loop_task = asyncio.create_task(self._run_loop()) + + async def stop(self) -> None: + self._stopping.set() + if self._loop_task is not None: + self._loop_task.cancel() + try: + await self._loop_task + except asyncio.CancelledError: + pass + self._loop_task = None + + async def _run_loop(self) -> None: + try: + await self.recover() + except Exception as exc: # pragma: no cover - defensive + print(f"[monitor.scheduler] recovery failed: {exc}") + + while not self._stopping.is_set(): + try: + await self.tick() + except Exception as exc: # pragma: no cover - keep the loop alive + print(f"[monitor.scheduler] tick failed: {exc}") + await asyncio.sleep(POLL_INTERVAL_SECONDS) + + async def recover(self) -> None: + """Clean up state left behind by a server restart. + + A run still marked ``running`` cannot be running -- its subprocess died + with the previous process. Marking it interrupted stops it from blocking + the UI as a phantom in-flight run. + """ + async with get_session() as session: + stale = ( + await session.scalars( + select(MonitorRun).where(MonitorRun.status == RUN_RUNNING) + ) + ).all() + for run in stale: + run.status = RUN_INTERRUPTED + run.finished_at = get_current_timestamp() + if stale: + print( + f"[monitor.scheduler] marked {len(stale)} interrupted run(s) " + f"left over from a previous process" + ) + + async def tick(self) -> None: + """Run one due task, if the crawler is free and we are in the active window.""" + # The crawler subprocess is a global singleton, so a manual crawl and a + # monitor run cannot overlap. Returning without advancing next_run_at + # leaves the task due, and it is picked up on a later tick. + if crawler_manager.is_busy(): + return + + async with get_session() as session: + if not await self._within_active_hours(session): + # Deliberately does not advance next_run_at: the task simply runs + # when the window next opens, rather than being skipped for a day. + return + + await self._run_due_task() + + async def _within_active_hours(self, session) -> bool: + """Whether scheduled runs are allowed right now (local time).""" + start, end = await app_settings.active_hours(session) + hour = datetime.now().hour + if start <= end: + return start <= hour <= end + # Window wraps past midnight, e.g. 22 -> 6. + return hour >= start or hour <= end + + async def _run_due_task(self) -> None: + async with get_session() as session: + task = await session.scalar( + select(MonitorTask) + .where( + MonitorTask.enabled.is_(True), + MonitorTask.next_run_at.is_not(None), + MonitorTask.next_run_at <= get_current_timestamp(), + ) + .order_by(MonitorTask.next_run_at) + .limit(1) + ) + + if task is None: + return + + # No cookie means every run would report an auth failure. Leave the + # task due rather than advancing: it starts working the moment the + # user pastes one. + cookie = await get_cookie(session) + if not cookie: + if not self._warned_no_cookie: + print( + "[monitor.scheduler] no XHS cookie configured; " + "scheduled tasks will not run until one is set" + ) + self._warned_no_cookie = True + return + self._warned_no_cookie = False + + # Advance before running so a crash mid-run cannot cause an immediate + # re-fire, and so a long outage coalesces into a single run instead + # of one run per missed interval. + task.next_run_at = ( + get_current_timestamp() + + task.interval_minutes * _MS_PER_MINUTE + + random.randint(0, JITTER_SECONDS) * 1000 + ) + task_id = task.id + + try: + await execute_task(task_id, trigger="scheduled") + except Exception as exc: + print(f"[monitor.scheduler] task {task_id} failed: {exc}") + + +# Global singleton, mirroring the crawler_manager pattern. +monitor_scheduler = MonitorScheduler() diff --git a/api/monitor/service.py b/api/monitor/service.py new file mode 100644 index 0000000..20c03bd --- /dev/null +++ b/api/monitor/service.py @@ -0,0 +1,720 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/service.py +# GitHub: https://github.com/NanmiCoder +# Non-commercial learning license 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Task CRUD and dashboard queries for the monitoring layer.""" + +import asyncio +import re +from typing import Any, Dict, List, Optional +from urllib.parse import parse_qs, urlparse + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from tools.time_util import get_current_timestamp + +from . import app_settings, platforms +from .db import get_session +from .platforms import PLATFORM_XHS +from .models import ( + MODE_CREATOR, + MODE_NOTE, + MonitorComment, + MonitorEvent, + MonitorNote, + MonitorNoteMetric, + MonitorRun, + MonitorTarget, + MonitorTask, + RUN_SUCCESS, + RUN_PARTIAL, +) +from .runner import execute_task + +# Keep strong references to in-flight manual runs; asyncio only holds weak ones, +# so without this a run can be garbage collected mid-flight. +_background_runs: set[asyncio.Task] = set() + +MIN_INTERVAL_MINUTES = 30 +MAX_INTERVAL_MINUTES = 7 * 24 * 60 + +_CREATOR_URL_RE = re.compile(r"xiaohongshu\.com/user/profile/([A-Za-z0-9_-]+)") +_NOTE_URL_RE = re.compile(r"xiaohongshu\.com/(?:explore|discovery/item)/([A-Za-z0-9_-]+)") +# XHS user ids and note ids are 24-char hex; allow a slightly wider range so a +# format change degrades into "still accepted" rather than "rejected". +_BARE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$") + + +class TargetParseError(ValueError): + """Raised when a pasted monitoring target cannot be understood.""" + + +def parse_target_input( + value: str, mode: str, platform: str = PLATFORM_XHS +) -> Dict[str, str]: + """Parse a pasted creator/note value into a stable id plus a refreshable token. + + Accepts either a full URL (with or without ``xsec_token``) or a bare id. + Storing the id separately from the token is what keeps a long-running task + alive: tokens expire, ids do not. + + URL shapes are platform-specific. Only Xiaohongshu is wired, so anything else + is rejected here as well as at task creation -- parsing a Douyin link as if it + were a Xiaohongshu one would be worse than refusing it. + """ + if platform != PLATFORM_XHS: + raise TargetParseError(f"暂不支持解析该平台({platform})的目标链接") + + raw = (value or "").strip() + if not raw: + raise TargetParseError("Empty target") + + external_id = "" + if raw.startswith("http") or "/" in raw: + # xhslink.com and other short links are not resolvable without a network + # round-trip, so only the direct profile/explore forms are supported. + match = _CREATOR_URL_RE.search(raw) if mode == MODE_CREATOR else _NOTE_URL_RE.search(raw) + if not match: + expected = "博主主页" if mode == MODE_CREATOR else "笔记" + raise TargetParseError(f"无法从链接中解析出{expected} ID:{raw}") + external_id = match.group(1) + elif _BARE_ID_RE.match(raw): + external_id = raw + else: + raise TargetParseError(f"无法识别的目标:{raw}") + + params = parse_qs(urlparse(raw).query) if raw.startswith("http") else {} + return { + "external_id": external_id, + "xsec_token": (params.get("xsec_token") or [""])[0], + "xsec_source": (params.get("xsec_source") or [""])[0], + "raw_value": raw, + } + + +async def platform_task_ids(session: AsyncSession, platform: str) -> List[int]: + """Ids of the tasks belonging to a platform. + + Note/comment/event tables carry no platform column -- they hang off a task -- + so scoping a query to a platform means scoping it to that task set. + """ + return list( + await session.scalars(select(MonitorTask.id).where(MonitorTask.platform == platform)) + ) + + +# --------------------------------------------------------------------------- +# Task CRUD +# --------------------------------------------------------------------------- + +async def create_task(session: AsyncSession, payload: Dict[str, Any]) -> MonitorTask: + mode = payload["mode"] + if mode not in (MODE_CREATOR, MODE_NOTE): + raise ValueError(f"Unsupported mode: {mode}") + + # Rejects unknown platforms and, more importantly, platforms whose crawler + # exists upstream but whose monitoring is not wired up -- accepting those + # would create a task that can never produce data. + platform = payload.get("platform") or PLATFORM_XHS + platforms.ensure_runnable(platform) + + now = get_current_timestamp() + + # Fall back to the configured defaults for anything the caller left out, so + # the Settings page actually governs new tasks. + defaults = await app_settings.defaults(session, platform) + interval_minutes = payload.get("interval_minutes") or defaults["interval_minutes"] + interval_ms = int(interval_minutes) * 60_000 + + task = MonitorTask( + name=payload["name"], + platform=platform, + mode=mode, + enabled=payload.get("enabled", True), + interval_minutes=interval_minutes, + max_notes_count=payload.get("max_notes_count") or defaults["max_notes_count"], + enable_comments=payload.get("enable_comments", True), + max_comments_count=payload.get("max_comments_count") or defaults["max_comments_count"], + run_timeout_seconds=payload.get("run_timeout_seconds", 3600), + notify_enabled=payload.get("notify_enabled", False), + next_run_at=now + interval_ms, + last_status="idle", + created_at=now, + updated_at=now, + ) + session.add(task) + await session.flush() + + seen: set[str] = set() + for value in payload.get("targets", []): + parsed = parse_target_input(value, mode, platform) + if parsed["external_id"] in seen: + continue + seen.add(parsed["external_id"]) + session.add( + MonitorTarget( + task_id=task.id, + kind=mode, + external_id=parsed["external_id"], + xsec_token=parsed["xsec_token"], + xsec_source=parsed["xsec_source"], + raw_value=parsed["raw_value"], + label=parsed["external_id"], + enabled=True, + created_at=now, + ) + ) + + await session.flush() + return task + + +async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, Any]) -> MonitorTask: + task = await session.get(MonitorTask, task_id) + if task is None: + raise ValueError(f"Task {task_id} not found") + + for field in ( + "name", + "enabled", + "interval_minutes", + "max_notes_count", + "enable_comments", + "max_comments_count", + "run_timeout_seconds", + "notify_enabled", + ): + if field in payload and payload[field] is not None: + setattr(task, field, payload[field]) + + # Replacing targets resets the baseline implicitly: a note set that now + # includes new ids will simply report them as new on the next run. + if payload.get("targets") is not None: + await session.execute(delete(MonitorTarget).where(MonitorTarget.task_id == task_id)) + now = get_current_timestamp() + seen: set[str] = set() + for value in payload["targets"]: + parsed = parse_target_input(value, task.mode) + if parsed["external_id"] in seen: + continue + seen.add(parsed["external_id"]) + session.add( + MonitorTarget( + task_id=task_id, + kind=task.mode, + external_id=parsed["external_id"], + xsec_token=parsed["xsec_token"], + xsec_source=parsed["xsec_source"], + raw_value=parsed["raw_value"], + label=parsed["external_id"], + enabled=True, + created_at=now, + ) + ) + + if "interval_minutes" in payload and payload["interval_minutes"]: + task.next_run_at = get_current_timestamp() + payload["interval_minutes"] * 60_000 + + task.updated_at = get_current_timestamp() + await session.flush() + return task + + +async def delete_task(session: AsyncSession, task_id: int) -> None: + task = await session.get(MonitorTask, task_id) + if task is None: + raise ValueError(f"Task {task_id} not found") + await session.delete(task) + + +def trigger_manual_run(task_id: int) -> None: + """Fire a run in the background and return immediately. + + A crawl takes minutes, so the HTTP request must not wait for it. The UI + follows progress through the logs WebSocket and the run history. + """ + task = asyncio.create_task(execute_task(task_id, trigger="manual")) + _background_runs.add(task) + task.add_done_callback(_background_runs.discard) + + +# --------------------------------------------------------------------------- +# Dashboard queries +# --------------------------------------------------------------------------- + +async def _latest_successful_run_id(session: AsyncSession, task_id: int) -> Optional[int]: + return await session.scalar( + select(MonitorRun.id) + .where( + MonitorRun.task_id == task_id, + MonitorRun.status.in_((RUN_SUCCESS, RUN_PARTIAL)), + ) + .order_by(MonitorRun.id.desc()) + .limit(1) + ) + + +def _delta(current: Optional[int], previous: Optional[int]) -> Optional[int]: + if current is None or previous is None: + return None + return current - previous + + +async def list_notes( + session: AsyncSession, + task_id: Optional[int] = None, + only_new: bool = False, + limit: int = 200, + platform: Optional[str] = None, +) -> List[Dict[str, Any]]: + """Tracked notes with their latest metrics and change vs the previous run.""" + query = select(MonitorNote).order_by(MonitorNote.last_seen_at.desc()).limit(limit) + if task_id is not None: + query = query.where(MonitorNote.task_id == task_id) + if platform is not None: + scoped = await platform_task_ids(session, platform) + if not scoped: + return [] + query = query.where(MonitorNote.task_id.in_(scoped)) + + notes = list((await session.scalars(query)).all()) + if not notes: + return [] + + note_ids = [note.note_id for note in notes] + + # Fetch every snapshot for these notes in one go and gather the two most + # recent per note, rather than issuing two queries per note. + snapshots = list( + ( + await session.scalars( + select(MonitorNoteMetric) + .where(MonitorNoteMetric.note_id.in_(note_ids)) + .order_by(MonitorNoteMetric.note_id, MonitorNoteMetric.run_id.desc()) + ) + ).all() + ) + by_note: Dict[str, List[MonitorNoteMetric]] = {} + for snapshot in snapshots: + by_note.setdefault(snapshot.note_id, []).append(snapshot) + + latest_run_ids: Dict[int, Optional[int]] = {} + result: List[Dict[str, Any]] = [] + + for note in notes: + series = by_note.get(note.note_id, []) + current = series[0] if series else None + previous = series[1] if len(series) > 1 else None + + if only_new: + if note.task_id not in latest_run_ids: + latest_run_ids[note.task_id] = await _latest_successful_run_id(session, note.task_id) + if note.first_seen_run_id != latest_run_ids[note.task_id]: + continue + + result.append( + { + "task_id": note.task_id, + "note_id": note.note_id, + "title": note.title, + "note_url": note.note_url, + "cover": note.cover, + "first_seen_at": note.first_seen_at, + "last_seen_at": note.last_seen_at, + "is_new": note.first_seen_run_id == latest_run_ids.get(note.task_id), + "metrics": { + "liked_count": current.liked_count if current else None, + "comment_count": current.comment_count if current else None, + "collected_count": current.collected_count if current else None, + "share_count": current.share_count if current else None, + }, + "deltas": { + "liked_count": _delta( + current.liked_count if current else None, + previous.liked_count if previous else None, + ), + "comment_count": _delta( + current.comment_count if current else None, + previous.comment_count if previous else None, + ), + "collected_count": _delta( + current.collected_count if current else None, + previous.collected_count if previous else None, + ), + "share_count": _delta( + current.share_count if current else None, + previous.share_count if previous else None, + ), + }, + "snapshot_count": len(series), + } + ) + + return result + + +async def note_series(session: AsyncSession, note_id: str, task_id: Optional[int] = None) -> List[Dict[str, Any]]: + """Metric time series for one note.""" + query = ( + select(MonitorNoteMetric) + .where(MonitorNoteMetric.note_id == note_id) + .order_by(MonitorNoteMetric.run_id) + ) + if task_id is not None: + query = query.where(MonitorNoteMetric.task_id == task_id) + + return [ + { + "run_id": row.run_id, + "captured_at": row.captured_at, + "liked_count": row.liked_count, + "comment_count": row.comment_count, + "collected_count": row.collected_count, + "share_count": row.share_count, + } + for row in (await session.scalars(query)).all() + ] + + +async def _note_meta_map( + session: AsyncSession, note_ids: List[str] +) -> Dict[str, Dict[str, Any]]: + """Look up note title/cover/url for a set of note ids. + + Fetched as one query and joined in Python rather than as a SQL join: the + comment table has no foreign key to the note table (both are keyed by the + platform's note id, per task), and a single IN() is easier to follow here. + """ + if not note_ids: + return {} + + rows = ( + await session.scalars(select(MonitorNote).where(MonitorNote.note_id.in_(set(note_ids)))) + ).all() + return { + row.note_id: { + "note_title": row.title, + "note_cover": row.cover, + "note_url": row.note_url, + "task_id": row.task_id, + } + for row in rows + } + + +async def list_comments( + session: AsyncSession, + task_id: Optional[int] = None, + note_id: Optional[str] = None, + limit: int = 200, + platform: Optional[str] = None, +) -> List[Dict[str, Any]]: + """Comments, each carrying the note it belongs to. + + The note association is the point: without it a comment stream is unreadable, + since a bare note_id tells the operator nothing. + """ + query = select(MonitorComment).order_by(MonitorComment.first_seen_at.desc()).limit(limit) + if task_id is not None: + query = query.where(MonitorComment.task_id == task_id) + if note_id is not None: + query = query.where(MonitorComment.note_id == note_id) + if platform is not None: + scoped = await platform_task_ids(session, platform) + if not scoped: + return [] + query = query.where(MonitorComment.task_id.in_(scoped)) + + comments = list((await session.scalars(query)).all()) + meta = await _note_meta_map(session, [row.note_id for row in comments]) + + return [ + { + "task_id": row.task_id, + "note_id": row.note_id, + "comment_id": row.comment_id, + "content": row.content, + "nickname": row.nickname, + "create_time": row.create_time, + "like_count": row.like_count, + "sub_comment_count": row.sub_comment_count, + "first_seen_at": row.first_seen_at, + "note_title": meta.get(row.note_id, {}).get("note_title", ""), + "note_cover": meta.get(row.note_id, {}).get("note_cover", ""), + "note_url": meta.get(row.note_id, {}).get("note_url", ""), + } + for row in comments + ] + + +async def comment_note_groups( + session: AsyncSession, task_id: Optional[int] = None, platform: Optional[str] = None +) -> List[Dict[str, Any]]: + """Notes that have comments, newest first, with their comment counts. + + Feeds the comment filter dropdown: the operator picks a work by title, so + the counts need to be visible before choosing. + """ + scoped_ids: Optional[List[int]] = None + if platform is not None: + scoped_ids = await platform_task_ids(session, platform) + if not scoped_ids: + return [] + + count_query = select( + MonitorComment.note_id, func.count().label("comment_count") + ).group_by(MonitorComment.note_id) + if task_id is not None: + count_query = count_query.where(MonitorComment.task_id == task_id) + if scoped_ids is not None: + count_query = count_query.where(MonitorComment.task_id.in_(scoped_ids)) + + counts = {row.note_id: row.comment_count for row in (await session.execute(count_query)).all()} + if not counts: + return [] + + latest_query = ( + select(MonitorComment.note_id, func.max(MonitorComment.first_seen_at).label("latest")) + .where(MonitorComment.note_id.in_(set(counts))) + .group_by(MonitorComment.note_id) + ) + if task_id is not None: + latest_query = latest_query.where(MonitorComment.task_id == task_id) + if scoped_ids is not None: + latest_query = latest_query.where(MonitorComment.task_id.in_(scoped_ids)) + latest = {row.note_id: row.latest for row in (await session.execute(latest_query)).all()} + + meta = await _note_meta_map(session, list(counts)) + + groups = [ + { + "note_id": note_id, + "note_title": meta.get(note_id, {}).get("note_title", ""), + "note_cover": meta.get(note_id, {}).get("note_cover", ""), + "note_url": meta.get(note_id, {}).get("note_url", ""), + "comment_count": count, + "latest_at": latest.get(note_id, 0), + } + for note_id, count in counts.items() + ] + groups.sort(key=lambda group: group["latest_at"], reverse=True) + return groups + + +async def list_events( + session: AsyncSession, + task_id: Optional[int] = None, + event_type: Optional[str] = None, + since_id: Optional[int] = None, + limit: int = 200, + platform: Optional[str] = None, +) -> List[Dict[str, Any]]: + query = select(MonitorEvent).order_by(MonitorEvent.id.desc()).limit(limit) + if task_id is not None: + query = query.where(MonitorEvent.task_id == task_id) + if event_type is not None: + query = query.where(MonitorEvent.type == event_type) + if since_id is not None: + query = query.where(MonitorEvent.id > since_id) + if platform is not None: + scoped = await platform_task_ids(session, platform) + if not scoped: + return [] + query = query.where(MonitorEvent.task_id.in_(scoped)) + + return [ + { + "id": row.id, + "task_id": row.task_id, + "run_id": row.run_id, + "type": row.type, + "severity": row.severity, + "target_kind": row.target_kind, + "target_id": row.target_id, + "title": row.title, + "created_at": row.created_at, + "is_read": row.is_read, + } + for row in (await session.scalars(query)).all() + ] + + +async def list_runs(session: AsyncSession, task_id: int, limit: int = 50) -> List[Dict[str, Any]]: + rows = ( + await session.scalars( + select(MonitorRun) + .where(MonitorRun.task_id == task_id) + .order_by(MonitorRun.id.desc()) + .limit(limit) + ) + ).all() + + return [ + { + "id": row.id, + "task_id": row.task_id, + "status": row.status, + "trigger": row.trigger, + "queued_at": row.queued_at, + "started_at": row.started_at, + "finished_at": row.finished_at, + "exit_code": row.exit_code, + "notes_fetched": row.notes_fetched, + "comments_fetched": row.comments_fetched, + "new_notes": row.new_notes, + "new_comments": row.new_comments, + "is_baseline": row.is_baseline, + "max_comments_count": row.max_comments_count, + "error_message": row.error_message, + } + for row in rows + ] + + +async def list_tasks( + session: AsyncSession, platform: Optional[str] = None +) -> List[Dict[str, Any]]: + query = select(MonitorTask).order_by(MonitorTask.id) + if platform is not None: + query = query.where(MonitorTask.platform == platform) + + tasks = list((await session.scalars(query)).all()) + if not tasks: + return [] + + counts = dict( + ( + await session.execute( + select(MonitorTarget.task_id, func.count()) + .group_by(MonitorTarget.task_id) + ) + ).all() + ) + unread = dict( + ( + await session.execute( + select(MonitorEvent.task_id, func.count()) + .where(MonitorEvent.is_read.is_(False)) + .group_by(MonitorEvent.task_id) + ) + ).all() + ) + + return [ + { + "id": task.id, + "name": task.name, + "platform": task.platform, + "mode": task.mode, + "enabled": task.enabled, + "interval_minutes": task.interval_minutes, + "max_notes_count": task.max_notes_count, + "enable_comments": task.enable_comments, + "max_comments_count": task.max_comments_count, + "run_timeout_seconds": task.run_timeout_seconds, + "notify_enabled": task.notify_enabled, + "next_run_at": task.next_run_at, + "last_run_at": task.last_run_at, + "last_status": task.last_status, + "last_error": task.last_error, + "last_notified_at": task.last_notified_at, + "target_count": counts.get(task.id, 0), + "targets": [ + {"id": t.id, "external_id": t.external_id, "raw_value": t.raw_value, "enabled": t.enabled} + for t in task.targets + ], + "unread_events": unread.get(task.id, 0), + } + for task in tasks + ] + + +async def overview(session: AsyncSession, platform: Optional[str] = None) -> Dict[str, Any]: + """Headline numbers for the dashboard tiles, scoped to one platform.""" + now = get_current_timestamp() + day_ago = now - 24 * 60 * 60 * 1000 + + # Nothing but the task table carries a platform column, so the other counts + # are scoped through the platform's task ids. + scoped: Optional[List[int]] = None + if platform is not None: + scoped = await platform_task_ids(session, platform) + + def by_task(stmt, column): + return stmt if scoped is None else stmt.where(column.in_(scoped)) + + task_count = select(func.count()).select_from(MonitorTask) + if platform is not None: + task_count = task_count.where(MonitorTask.platform == platform) + + enabled_count = select(func.count()).select_from(MonitorTask).where( + MonitorTask.enabled.is_(True) + ) + if platform is not None: + enabled_count = enabled_count.where(MonitorTask.platform == platform) + + return { + "platform": platform, + "tasks": await session.scalar(task_count) or 0, + "enabled_tasks": await session.scalar(enabled_count) or 0, + "notes": await session.scalar( + by_task(select(func.count()).select_from(MonitorNote), MonitorNote.task_id) + ) + or 0, + "comments": await session.scalar( + by_task(select(func.count()).select_from(MonitorComment), MonitorComment.task_id) + ) + or 0, + "events_24h": await session.scalar( + by_task( + select(func.count()) + .select_from(MonitorEvent) + .where(MonitorEvent.created_at >= day_ago), + MonitorEvent.task_id, + ) + ) + or 0, + "unread_events": await session.scalar( + by_task( + select(func.count()) + .select_from(MonitorEvent) + .where(MonitorEvent.is_read.is_(False)), + MonitorEvent.task_id, + ) + ) + or 0, + "running_runs": await session.scalar( + by_task( + select(func.count()) + .select_from(MonitorRun) + .where(MonitorRun.status == "running"), + MonitorRun.task_id, + ) + ) + or 0, + } + + +async def mark_events_read(session: AsyncSession, task_id: Optional[int] = None) -> int: + query = select(MonitorEvent).where(MonitorEvent.is_read.is_(False)) + if task_id is not None: + query = query.where(MonitorEvent.task_id == task_id) + rows = list((await session.scalars(query)).all()) + for row in rows: + row.is_read = True + return len(rows) diff --git a/api/monitor/settings.py b/api/monitor/settings.py new file mode 100644 index 0000000..8ac1f4b --- /dev/null +++ b/api/monitor/settings.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/settings.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Key/value settings for the monitoring layer, plus cookie health helpers. + +The XHS cookie is what makes scheduled runs unattended. It expires every few +weeks, so alongside the value we track when it was last seen working -- that is +what lets the UI warn before a task silently stops collecting. +""" + +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from tools.time_util import get_current_timestamp + +from .models import MonitorSetting +from .platforms import PLATFORM_XHS + + +def platform_key(platform: str, name: str) -> str: + """Key for a setting that each platform keeps its own copy of.""" + return f"platform.{platform}.{name}" + + +def system_key(name: str) -> str: + """Key for a setting shared across every platform.""" + return f"system.{name}" + + +def cookie_key(platform: str) -> str: + return platform_key(platform, "cookie") + + +def cookie_updated_key(platform: str) -> str: + return platform_key(platform, "cookie_updated_at") + + +def cookie_last_ok_key(platform: str) -> str: + return platform_key(platform, "cookie_last_ok_at") + + +async def get_setting(session: AsyncSession, key: str) -> Optional[str]: + return await session.scalar(select(MonitorSetting.value).where(MonitorSetting.key == key)) + + +async def set_setting(session: AsyncSession, key: str, value: str) -> None: + row = await session.get(MonitorSetting, key) + now = get_current_timestamp() + if row is None: + session.add(MonitorSetting(key=key, value=value, updated_at=now)) + else: + row.value = value + row.updated_at = now + + +async def delete_setting(session: AsyncSession, key: str) -> None: + row = await session.get(MonitorSetting, key) + if row is not None: + await session.delete(row) + + +async def get_cookie(session: AsyncSession, platform: str = PLATFORM_XHS) -> str: + return (await get_setting(session, cookie_key(platform))) or "" + + +async def set_cookie( + session: AsyncSession, cookie: str, platform: str = PLATFORM_XHS +) -> None: + await set_setting(session, cookie_key(platform), cookie) + await set_setting(session, cookie_updated_key(platform), str(get_current_timestamp())) + + +async def mark_cookie_ok(session: AsyncSession, platform: str = PLATFORM_XHS) -> None: + """Record that a run authenticated successfully.""" + await set_setting(session, cookie_last_ok_key(platform), str(get_current_timestamp())) + + +async def get_cookie_status(session: AsyncSession, platform: str = PLATFORM_XHS) -> dict: + """Cookie health for the UI. Never returns the cookie value itself.""" + cookie = await get_cookie(session, platform) + updated_at = await get_setting(session, cookie_updated_key(platform)) + last_ok_at = await get_setting(session, cookie_last_ok_key(platform)) + + return { + "platform": platform, + "present": bool(cookie), + # Enough to eyeball whether the pasted value looks right, not enough to leak it. + "length": len(cookie), + "updated_at": int(updated_at) if updated_at else None, + "last_ok_at": int(last_ok_at) if last_ok_at else None, + } diff --git a/api/routers/__init__.py b/api/routers/__init__.py index 123cbc0..70c6dc6 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -16,8 +16,18 @@ # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 +from .auth import router as auth_router from .crawler import router as crawler_router from .data import router as data_router +from .monitor import router as monitor_router +from .settings import router as settings_router from .websocket import router as websocket_router -__all__ = ["crawler_router", "data_router", "websocket_router"] +__all__ = [ + "auth_router", + "crawler_router", + "data_router", + "monitor_router", + "settings_router", + "websocket_router", +] diff --git a/api/routers/auth.py b/api/routers/auth.py new file mode 100644 index 0000000..852b9ed --- /dev/null +++ b/api/routers/auth.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/auth.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Login / logout endpoints. + +Deliberately exempt from ``require_auth``: +* ``/login`` -- it is the way in. +* ``/logout`` -- exempt so an already-expired session still gets a clean 200 + and a cleared cookie instead of a confusing 401, which + would leave the browser holding a stale cookie. +""" + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status + +from ..auth import ( + INVALID_CREDENTIALS, + SESSION_COOKIE_NAME, + check_password, + require_auth, + clear_failures, + client_key, + cookie_secure, + create_session, + purge_expired_sessions, + record_failure, + resolve_session, + retry_after_seconds, + revoke_all_sessions, + revoke_session, + set_password, + token_from_request, +) +from ..monitor.db import get_session +from ..schemas.auth import ChangePasswordPayload, LoginPayload +from tools.time_util import get_current_timestamp + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _apply_session_cookie(response: Response, token: str, expires_at: int) -> None: + """Attach the session cookie. + + ``secure`` is off by default because the panel is served over plain HTTP on + a LAN; setting it there means the browser silently discards the cookie and + the login page just loops with no error. ``SameSite=lax`` is also what + blocks cross-site POSTs, i.e. the CSRF defence for the write endpoints. + """ + max_age = max((expires_at - get_current_timestamp()) // 1000, 60) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + max_age=max_age, + httponly=True, + secure=cookie_secure(), + samesite="lax", + path="/", + ) + + +@router.post("/login") +async def login(payload: LoginPayload, request: Request, response: Response): + key = client_key(request) + + wait = await retry_after_seconds(key) + if wait: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"尝试过于频繁,请 {wait} 秒后再试", + headers={"Retry-After": str(wait)}, + ) + + async with get_session() as session: + valid = await check_password(session, payload.password) + token = "" + expires_at = 0 + if valid: + await purge_expired_sessions(session) + token, expires_at = await create_session(session) + + if not valid: + await record_failure(key) + # One generic message regardless of whether the password was wrong, + # empty, or simply not set yet -- no oracle. + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS + ) + + await clear_failures(key) + _apply_session_cookie(response, token, expires_at) + return {"expires_at": expires_at} + + +@router.post("/logout") +async def logout(request: Request, response: Response): + token = token_from_request(request) + if token: + async with get_session() as session: + await revoke_session(session, token) + + response.delete_cookie(SESSION_COOKIE_NAME, path="/") + return {"message": "已退出登录"} + + +@router.get("/me") +async def me(request: Request): + """Identity probe. The SPA treats a 401 here as "show the login page". + + Does its own resolution rather than using ``require_auth`` so it can also + report the expiry. + """ + token = token_from_request(request) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS + ) + + async with get_session() as session: + row = await resolve_session(session, token) + if row is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS + ) + return {"authenticated": True, "expires_at": row.expires_at} + + +# Auth as a route dependency, not only inside the handler: FastAPI validates the +# request body before the endpoint body runs, so an unauthenticated caller would +# otherwise get a 422 that confirms the endpoint and its schema exist. +@router.post("/password", dependencies=[Depends(require_auth)]) +async def change_password( + payload: ChangePasswordPayload, request: Request, response: Response +): + """Change the password and log every device out. + + Revoking all sessions is the point: a password change is usually a response + to suspicion, and leaving other sessions alive would defeat it. + """ + token = token_from_request(request) + + async with get_session() as session: + current = await resolve_session(session, token) + if current is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS + ) + + if not await check_password(session, payload.current): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="当前密码不正确" + ) + + await set_password(session, payload.new) + await revoke_all_sessions(session) + # Issue a fresh session so the caller is not bounced mid-use. + new_token, expires_at = await create_session(session) + + _apply_session_cookie(response, new_token, expires_at) + return {"message": "密码已更新,其他设备的登录已全部失效", "expires_at": expires_at} diff --git a/api/routers/crawler.py b/api/routers/crawler.py index eead9e1..7547721 100644 --- a/api/routers/crawler.py +++ b/api/routers/crawler.py @@ -18,7 +18,9 @@ from fastapi import APIRouter, HTTPException -from ..schemas import CrawlerStartRequest, CrawlerStatusResponse +from ..monitor.db import get_session +from ..monitor.settings import get_cookie +from ..schemas import CrawlerStartRequest, CrawlerStatusResponse, LoginTypeEnum from ..services import crawler_manager router = APIRouter(prefix="/crawler", tags=["crawler"]) @@ -26,7 +28,28 @@ router = APIRouter(prefix="/crawler", tags=["crawler"]) @router.post("/start") async def start_crawler(request: CrawlerStartRequest): - """Start crawler task""" + """Start crawler task. + + A cookie login with no cookie supplied falls back to the one stored for the + selected platform. The manual crawl and the monitor therefore share a single + credential; keeping a second paste field on the crawl page meant it went + stale and could silently disagree with the monitor's. + """ + if ( + request.login_type == LoginTypeEnum.COOKIE + and not request.cookies + and not request.cookies_file + ): + async with get_session() as session: + stored = await get_cookie(session, request.platform.value) + + if not stored: + raise HTTPException( + status_code=400, + detail="该平台尚未保存 Cookie,请到「设置 → 登录态」配置,或改用扫码登录", + ) + request.cookies = stored + success = await crawler_manager.start(request) if not success: # Handle concurrent/duplicate requests: if process is already running, return 400 instead of 500 diff --git a/api/routers/monitor.py b/api/routers/monitor.py new file mode 100644 index 0000000..1957b7f --- /dev/null +++ b/api/routers/monitor.py @@ -0,0 +1,502 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/monitor.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""HTTP API for scheduled monitoring tasks.""" + +from datetime import date, timedelta +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query, Response + +from ..monitor import notify, report, service +from ..monitor.db import get_session +from ..monitor.platforms import PLATFORM_XHS +from ..monitor.settings import ( + cookie_key, + delete_setting, + get_cookie_status, + get_setting, + set_cookie, + set_setting, +) +from ..monitor.models import SETTING_WECOM_WEBHOOK, MonitorTask +from ..schemas.monitor import ( + CookiePayload, + MonitorTaskCreate, + MonitorTaskUpdate, + WebhookPayload, + WebhookTestPayload, +) + +router = APIRouter(prefix="/monitor", tags=["monitor"]) + + +@router.get("/overview") +async def get_overview(platform: Optional[str] = None): + """Headline numbers for the dashboard tiles, scoped to one platform.""" + async with get_session() as session: + return await service.overview(session, platform) + + +# --------------------------------------------------------------------------- +# Tasks +# --------------------------------------------------------------------------- + +@router.get("/tasks") +async def list_tasks(platform: Optional[str] = None): + async with get_session() as session: + return {"tasks": await service.list_tasks(session, platform)} + + +@router.post("/tasks", status_code=201) +async def create_task(payload: MonitorTaskCreate): + async with get_session() as session: + try: + task = await service.create_task(session, payload.model_dump()) + except service.TargetParseError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"id": task.id, "message": "Monitoring task created"} + + +@router.patch("/tasks/{task_id}") +async def update_task(task_id: int, payload: MonitorTaskUpdate): + async with get_session() as session: + try: + await service.update_task(session, task_id, payload.model_dump(exclude_unset=True)) + except service.TargetParseError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + return {"message": "Monitoring task updated"} + + +@router.delete("/tasks/{task_id}") +async def delete_task(task_id: int): + async with get_session() as session: + try: + await service.delete_task(session, task_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + return {"message": "Monitoring task deleted"} + + +@router.post("/tasks/{task_id}/run") +async def run_task_now(task_id: int): + """Queue a run immediately and return; the crawl itself takes minutes.""" + async with get_session() as session: + task = await session.get(MonitorTask, task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"Task {task_id} not found") + if not any(target.enabled for target in task.targets): + raise HTTPException(status_code=400, detail="Task has no enabled targets") + + service.trigger_manual_run(task_id) + return {"message": "Run queued"} + + +@router.get("/tasks/{task_id}/runs") +async def list_runs(task_id: int, limit: int = Query(default=50, ge=1, le=500)): + async with get_session() as session: + return {"runs": await service.list_runs(session, task_id, limit=limit)} + + +# --------------------------------------------------------------------------- +# Collected data +# --------------------------------------------------------------------------- + +@router.get("/notes") +async def list_notes( + task_id: Optional[int] = None, + only_new: bool = False, + limit: int = Query(default=200, ge=1, le=2000), + platform: Optional[str] = None, +): + async with get_session() as session: + return { + "notes": await service.list_notes(session, task_id, only_new, limit, platform) + } + + +@router.get("/notes/{note_id}/series") +async def note_series(note_id: str, task_id: Optional[int] = None): + """Metric time series for a single note.""" + async with get_session() as session: + return {"series": await service.note_series(session, note_id, task_id)} + + +@router.get("/comments") +async def list_comments( + task_id: Optional[int] = None, + note_id: Optional[str] = None, + group_by: Optional[str] = Query( + default=None, description="传 note 则按作品分组返回,便于阅读" + ), + limit: int = Query(default=200, ge=1, le=2000), + platform: Optional[str] = None, +): + """Comments, each carrying the work it belongs to. + + ``note_id`` filters to one work; ``group_by=note`` returns them bucketed per + work instead of as a flat stream. + """ + async with get_session() as session: + comments = await service.list_comments(session, task_id, note_id, limit, platform) + + if group_by != "note": + return {"comments": comments, "total": len(comments)} + + buckets: Dict[str, Dict[str, Any]] = {} + for comment in comments: + bucket = buckets.setdefault( + comment["note_id"], + { + "note_id": comment["note_id"], + "note_title": comment["note_title"], + "note_cover": comment["note_cover"], + "note_url": comment["note_url"], + "comments": [], + }, + ) + bucket["comments"].append(comment) + + ordered = sorted( + buckets.values(), + key=lambda group: group["comments"][0]["first_seen_at"], + reverse=True, + ) + return {"groups": ordered, "total": len(comments)} + + +@router.get("/comment-notes") +async def list_comment_notes(task_id: Optional[int] = None, platform: Optional[str] = None): + """Works that have comments, newest first, with counts. + + Feeds the comment filter dropdown so the operator can pick by title. + """ + async with get_session() as session: + return {"notes": await service.comment_note_groups(session, task_id, platform)} + + +@router.get("/events") +async def list_events( + task_id: Optional[int] = None, + type: Optional[str] = None, + since_id: Optional[int] = None, + limit: int = Query(default=200, ge=1, le=2000), + platform: Optional[str] = None, +): + async with get_session() as session: + events = await service.list_events(session, task_id, type, since_id, limit, platform) + return {"events": events, "latest_id": events[0]["id"] if events else since_id} + + +@router.post("/events/read") +async def mark_events_read(task_id: Optional[int] = None): + async with get_session() as session: + count = await service.mark_events_read(session, task_id) + return {"marked": count} + + +# --------------------------------------------------------------------------- +# Cookie / login health +# --------------------------------------------------------------------------- + +@router.get("/cookie") +async def get_cookie_endpoint(platform: str = Query(default=PLATFORM_XHS)): + """Cookie health only -- deliberately never returns the cookie value. + + ``platform`` defaults to Xiaohongshu so existing callers keep working; the + key it reads is the namespaced one. + """ + async with get_session() as session: + return await get_cookie_status(session, platform) + + +@router.post("/cookie") +async def set_cookie_endpoint(payload: CookiePayload, platform: str = Query(default=PLATFORM_XHS)): + async with get_session() as session: + await set_cookie(session, payload.cookie.strip(), platform) + return {"message": "Cookie saved"} + + +@router.delete("/cookie") +async def clear_cookie_endpoint(platform: str = Query(default=PLATFORM_XHS)): + async with get_session() as session: + await delete_setting(session, cookie_key(platform)) + return {"message": "Cookie cleared"} + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + +async def _resolve_scope( + session, task_ids: Optional[List[int]], platform: Optional[str] +) -> Optional[List[int]]: + """Combine an explicit task selection with an optional platform filter. + + ``None`` means "no restriction"; an explicit list is intersected with the + platform's tasks so a stale selection cannot leak another platform's data + into a scoped report. + """ + if platform is None: + return task_ids + + platform_ids = set(await service.platform_task_ids(session, platform)) + if task_ids is None: + return list(platform_ids) + return [task for task in task_ids if task in platform_ids] + + +@router.get("/export") +async def export_data( + kind: str = Query(..., description="notes | comments | report"), + task_id: Optional[List[int]] = Query(default=None), + note_id: Optional[str] = None, + start_date: Optional[str] = Query(default=None, description="YYYY-MM-DD,report 用"), + end_date: Optional[str] = Query(default=None, description="YYYY-MM-DD,report 用"), + days: int = Query(default=7, ge=1, le=365), + file_format: str = Query(default="csv", alias="format", description="csv | xlsx"), + platform: Optional[str] = None, +): + """Download collected data as CSV or Excel. + + Reached by the browser as a navigation (``window.open``), which cannot carry + an Authorization header -- this is one of the reasons the session lives in a + cookie. + """ + if kind not in ("notes", "comments", "report"): + raise HTTPException(status_code=400, detail="kind 必须是 notes / comments / report") + if file_format not in ("csv", "xlsx"): + raise HTTPException(status_code=400, detail="format 必须是 csv 或 xlsx") + + async with get_session() as session: + scoped = await _resolve_scope(session, task_id, platform) + + if kind == "notes": + single_task = scoped[0] if scoped and len(scoped) == 1 else None + rows = await service.list_notes(session, single_task, False, 5000, platform) + elif kind == "comments": + single_task = scoped[0] if scoped and len(scoped) == 1 else None + rows = await service.list_comments(session, single_task, note_id, 5000, platform) + else: + try: + end_day = date.fromisoformat(end_date) if end_date else date.today() + start_day = ( + date.fromisoformat(start_date) + if start_date + else end_day - timedelta(days=days - 1) + ) + except ValueError: + raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") + # Not `report = ...`: that would make `report` a local name for the + # whole function and shadow the module import on this very line. + report_data = await report.build_report(session, scoped, start_day, end_day) + rows = report_data["rows"] + + if not rows: + raise HTTPException(status_code=404, detail="该范围内没有数据可导出") + + columns = _export_columns(kind) + stamp = date.today().isoformat() + # ASCII on purpose: a non-ASCII filename needs RFC 5987 encoding in + # Content-Disposition, and the plain `filename="..."` form used below would + # mangle it. + filename = f"export_{kind}_{stamp}.{file_format}" + + if file_format == "xlsx": + payload = _to_xlsx(rows, columns, kind) + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + else: + payload = _to_csv(rows, columns) + media_type = "text/csv; charset=utf-8" + + return Response( + content=payload, + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _export_columns(kind: str) -> List[tuple[str, str]]: + """(key, header) pairs per export kind.""" + if kind == "notes": + return [ + ("note_id", "作品ID"), + ("title", "标题"), + ("note_url", "链接"), + ("liked_count", "点赞"), + ("comment_count", "评论"), + ("collected_count", "收藏"), + ("share_count", "分享"), + ("liked_count_delta", "点赞增量"), + ("comment_count_delta", "评论增量"), + ("first_seen_at", "首次发现"), + ("last_seen_at", "最近采集"), + ] + if kind == "comments": + return [ + ("note_title", "所属作品"), + ("note_id", "作品ID"), + ("comment_id", "评论ID"), + ("content", "内容"), + ("nickname", "昵称"), + ("like_count", "点赞"), + ("sub_comment_count", "子评论数"), + ("create_time", "发布时间"), + ("first_seen_at", "首次发现"), + ] + return [ + ("date", "日期"), + ("new_notes", "新增作品"), + ("new_comments", "新增评论"), + ("liked_count_delta", "点赞增量"), + ("comment_count_delta", "评论增量"), + ("collected_count_delta", "收藏增量"), + ("share_count_delta", "分享增量"), + ] + + +def _cell(value: Any) -> Any: + if value is None: + return "" + if isinstance(value, (list, dict)): + return ", ".join(str(v) for v in value) if isinstance(value, list) else str(value) + return value + + +def _to_csv(rows: List[Dict[str, Any]], columns: List[tuple[str, str]]) -> bytes: + import csv + import io + + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow([header for _, header in columns]) + for row in rows: + writer.writerow([_cell(row.get(key)) for key, _ in columns]) + + # utf-8-sig: without the BOM Excel opens Chinese CSV as mojibake, which is + # the single most common complaint about CSV exports here. + return buffer.getvalue().encode("utf-8-sig") + + +def _to_xlsx(rows: List[Dict[str, Any]], columns: List[tuple[str, str]], sheet: str) -> bytes: + import io + + from openpyxl import Workbook + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = {"notes": "作品", "comments": "评论"}.get(sheet, "报表") + worksheet.append([header for _, header in columns]) + for row in rows: + worksheet.append([_cell(row.get(key)) for key, _ in columns]) + + output = io.BytesIO() + workbook.save(output) + return output.getvalue() + + +@router.get("/report") +async def get_report( + task_id: Optional[List[int]] = Query( + default=None, description="Repeat to include several tasks; omit for all" + ), + start_date: Optional[str] = Query(default=None, description="YYYY-MM-DD"), + end_date: Optional[str] = Query(default=None, description="YYYY-MM-DD"), + days: int = Query(default=7, ge=1, le=365, description="Window used when dates are omitted"), + platform: Optional[str] = None, +): + """Daily new-content counts and interaction deltas for the selected tasks.""" + try: + end_day = date.fromisoformat(end_date) if end_date else date.today() + start_day = date.fromisoformat(start_date) if start_date else end_day - timedelta(days=days - 1) + except ValueError: + raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") + + if start_day > end_day: + raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期") + + async with get_session() as session: + scoped = await _resolve_scope(session, task_id, platform) + return await report.build_report(session, scoped, start_day, end_day) + + +# --------------------------------------------------------------------------- +# WeCom webhook +# --------------------------------------------------------------------------- + +def _mask_webhook(url: str) -> str: + """Show enough of the URL to recognise it, without exposing the robot key.""" + if not url: + return "" + key_marker = "key=" + index = url.find(key_marker) + if index == -1: + return url[:12] + "..." if len(url) > 12 else url + prefix = url[: index + len(key_marker)] + key = url[index + len(key_marker) :] + if len(key) <= 8: + return prefix + "*" * len(key) + return f"{prefix}{key[:4]}...{key[-4:]}" + + +@router.get("/webhook") +async def get_webhook(): + async with get_session() as session: + url = (await get_setting(session, SETTING_WECOM_WEBHOOK)) or "" + return {"configured": bool(url), "masked": _mask_webhook(url)} + + +@router.post("/webhook") +async def set_webhook(payload: WebhookPayload): + url = payload.url.strip() + if url and "qyapi.weixin.qq.com" not in url: + # Catches the common mistake of pasting a group-chat invite or the app + # URL instead of the robot webhook. + raise HTTPException( + status_code=400, + detail="这不像企业微信机器人 Webhook 地址(应包含 qyapi.weixin.qq.com)", + ) + + async with get_session() as session: + await set_setting(session, SETTING_WECOM_WEBHOOK, url) + return {"message": "Webhook 已保存" if url else "Webhook 已清空", "configured": bool(url)} + + +@router.delete("/webhook") +async def clear_webhook(): + async with get_session() as session: + await delete_setting(session, SETTING_WECOM_WEBHOOK) + return {"message": "Webhook 已删除"} + + +@router.post("/webhook/test") +async def test_webhook(payload: WebhookTestPayload): + """Send a test message so the user can verify the robot works before relying on it.""" + async with get_session() as session: + url = payload.url.strip() if payload.url else await notify.get_webhook_url(session) + + ok, detail = await notify.send_wecom( + url, "**综合采集平台 通知测试**\n> 如果你看到这条消息,说明 Webhook 配置成功。" + ) + if not ok: + raise HTTPException(status_code=400, detail=detail) + return {"message": detail} diff --git a/api/routers/settings.py b/api/routers/settings.py new file mode 100644 index 0000000..8b4e0d1 --- /dev/null +++ b/api/routers/settings.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/settings.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Unified settings endpoint. + +Consolidates what used to be scattered across the monitor router. The older +``/api/monitor/cookie`` and ``/api/monitor/webhook`` endpoints are deliberately +left in place -- they still work and removing them would be a breaking change for +no gain. +""" + +from typing import Any, Dict + +from fastapi import APIRouter, HTTPException, Query + +from ..monitor import app_settings +from ..monitor.db import get_session +from ..monitor.platforms import PLATFORM_XHS +from ..schemas.settings import SettingsUpdatePayload + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("") +async def read_settings(platform: str = Query(default=PLATFORM_XHS)): + """Settings for one platform, plus the system-wide ones. + + Platform-scoped values are returned for the requested platform; system-scoped + values are the same regardless. Every spec carries its resolved ``key`` so the + UI can PUT changes straight back. + + Sensitive values are returned as ``{present, length}`` only. + """ + async with get_session() as session: + return await app_settings.get_all(session, platform) + + +@router.put("") +async def write_settings( + payload: SettingsUpdatePayload, platform: str = Query(default=PLATFORM_XHS) +): + """Partial update: only the keys present in the body are written. + + A key belonging to a different platform is rejected rather than written + somewhere unexpected. + """ + values: Dict[str, Any] = payload.values() + + async with get_session() as session: + try: + changed = await app_settings.update(session, values, platform) + except app_settings.SettingValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + return {"message": f"已保存 {len(changed)} 项设置", "changed": changed} diff --git a/api/routers/websocket.py b/api/routers/websocket.py index 215d4ee..a6c92f2 100644 --- a/api/routers/websocket.py +++ b/api/routers/websocket.py @@ -19,8 +19,9 @@ import asyncio from typing import Set, Optional -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect +from ..auth import require_ws_auth from ..services import crawler_manager router = APIRouter(tags=["websocket"]) @@ -86,7 +87,10 @@ def start_broadcaster(): _broadcaster_task = asyncio.create_task(log_broadcaster()) -@router.websocket("/ws/logs") +# Websocket routes need their own auth dependency: BaseHTTPMiddleware returns +# early for any non-http scope, and HTTP router-level dependencies do not reach +# websocket routes. Without this the live crawl log stream would be wide open. +@router.websocket("/ws/logs", dependencies=[Depends(require_ws_auth)]) async def websocket_logs(websocket: WebSocket): """WebSocket log stream""" print("[WS] New connection attempt") @@ -134,7 +138,7 @@ async def websocket_logs(websocket: WebSocket): print(f"[WS] Cleanup done, active connections: {len(manager.active_connections)}") -@router.websocket("/ws/status") +@router.websocket("/ws/status", dependencies=[Depends(require_ws_auth)]) async def websocket_status(websocket: WebSocket): """WebSocket status stream""" await websocket.accept() diff --git a/api/schemas/auth.py b/api/schemas/auth.py new file mode 100644 index 0000000..8f76c58 --- /dev/null +++ b/api/schemas/auth.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/schemas/auth.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Request models for authentication endpoints.""" + +from pydantic import BaseModel, Field + +# A floor, not a policy: this is a single-operator internal panel, so the goal is +# only to reject obviously weak input. +MIN_PASSWORD_LENGTH = 8 + + +class LoginPayload(BaseModel): + password: str = Field(min_length=1) + + +class ChangePasswordPayload(BaseModel): + current: str = Field(min_length=1) + new: str = Field(min_length=MIN_PASSWORD_LENGTH, max_length=256) diff --git a/api/schemas/crawler.py b/api/schemas/crawler.py index cfc995e..c9bcc8b 100644 --- a/api/schemas/crawler.py +++ b/api/schemas/crawler.py @@ -78,6 +78,34 @@ class CrawlerStartRequest(BaseModel): max_notes_count: Optional[int] = Field(default=None, ge=1, le=MAX_API_LIMIT_COUNT) max_comments_count: Optional[int] = Field(default=None, ge=1, le=MAX_API_LIMIT_COUNT) + # --- Options only used by scheduled monitor runs. Each defaults to None so + # the corresponding CLI flag is omitted entirely for manual Crawl-tab runs, + # which keeps their behaviour byte-identical to before. + + # Isolate this run's output in its own directory. The crawler's own file + # writer names files by date only, so same-day runs would otherwise append + # into one shared file and could not be told apart. + save_data_path: Optional[str] = None + # Unattended runs must not try to attach to the user's desktop Chrome. + enable_cdp_mode: Optional[bool] = None + # XHS cookie login only injects `web_session` by default, which is not enough + # to sign API requests from a cold browser profile. + inject_all_cookies: Optional[bool] = None + save_login_state: Optional[bool] = None + # Preferred over `cookies`: a value on the command line is visible in the + # process list. + cookies_file: Optional[str] = None + max_concurrency_num: Optional[int] = Field(default=None, ge=1, le=MAX_API_LIMIT_COUNT) + + # Crawl-strategy and proxy knobs surfaced on the Settings page. Like the + # fields above, each stays None unless the caller sets it, so the CLI flag is + # omitted entirely and the config-file default applies. + crawler_max_sleep_sec: Optional[int] = Field(default=None, ge=0, le=600) + enable_ip_proxy: Optional[bool] = None + ip_proxy_pool_count: Optional[int] = Field(default=None, ge=1, le=100) + ip_proxy_provider_name: Optional[str] = None + static_proxy_url: Optional[str] = None + class CrawlerStatusResponse(BaseModel): """Crawler status response""" diff --git a/api/schemas/monitor.py b/api/schemas/monitor.py new file mode 100644 index 0000000..f396503 --- /dev/null +++ b/api/schemas/monitor.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/schemas/monitor.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Request models for the monitoring API.""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + +# A floor on the interval is a correctness guard, not a nicety: every run +# launches a browser and hits XHS with several requests, so a short interval +# across many creators is the pattern that triggers rate limiting. +MIN_INTERVAL_MINUTES = 30 +MAX_INTERVAL_MINUTES = 7 * 24 * 60 + + +class MonitorTaskCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + platform: str = "xhs" + # One subprocess handles exactly one crawler type, so a task is either + # creator-driven or note-driven. + mode: Literal["creator", "note"] + # None means "use the value configured on the Settings page", which is what + # makes those defaults meaningful. Bounds still apply when a value is given. + interval_minutes: Optional[int] = Field( + default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES + ) + max_notes_count: Optional[int] = Field(default=None, ge=1, le=500) + enable_comments: bool = True + # Raising this widens the comment window, which is the only lever available + # for noticing new comments -- the API has no time-sort. + max_comments_count: Optional[int] = Field(default=None, ge=1, le=500) + run_timeout_seconds: int = Field(default=3600, ge=60, le=86400) + enabled: bool = True + # Push a WeCom summary for runs that failed or found new works. Opt-in per + # task so a single webhook does not get flooded. + notify_enabled: bool = False + # Raw pasted values: full URLs or bare ids, in either form. + targets: List[str] = Field(min_length=1) + + +class MonitorTaskUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=200) + enabled: Optional[bool] = None + interval_minutes: Optional[int] = Field( + default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES + ) + max_notes_count: Optional[int] = Field(default=None, ge=1, le=500) + enable_comments: Optional[bool] = None + max_comments_count: Optional[int] = Field(default=None, ge=1, le=500) + run_timeout_seconds: Optional[int] = Field(default=None, ge=60, le=86400) + notify_enabled: Optional[bool] = None + # When present, replaces the whole target list. + targets: Optional[List[str]] = None + + +class CookiePayload(BaseModel): + cookie: str = Field(min_length=1) + + +class WebhookPayload(BaseModel): + url: str = Field(default="", description="企业微信机器人 Webhook 地址,留空表示停用") + + +class WebhookTestPayload(BaseModel): + url: Optional[str] = Field(default=None, description="不传则使用已保存的地址") diff --git a/api/schemas/settings.py b/api/schemas/settings.py new file mode 100644 index 0000000..148033e --- /dev/null +++ b/api/schemas/settings.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/schemas/settings.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Request model for the settings endpoint.""" + +from typing import Any, Dict + +from pydantic import BaseModel, ConfigDict + + +class SettingsUpdatePayload(BaseModel): + """Partial update of arbitrary setting keys. + + Fields are not declared here on purpose: ``api/monitor/app_settings.py`` + owns the registry (key, type, bounds, choices) and validates against it, so + adding a setting does not mean editing a matching schema. + """ + + model_config = ConfigDict(extra="allow") + + def values(self) -> Dict[str, Any]: + return dict(self.model_extra or {}) diff --git a/api/services/crawler_manager.py b/api/services/crawler_manager.py index 83119cc..95138c5 100644 --- a/api/services/crawler_manager.py +++ b/api/services/crawler_manager.py @@ -25,6 +25,7 @@ from datetime import datetime from pathlib import Path from ..schemas import CrawlerStartRequest, LogEntry +from .interpreter import resolve_python_cmd class CrawlerManager: @@ -43,6 +44,11 @@ class CrawlerManager: self._project_root = Path(__file__).parent.parent.parent # Log queue - for pushing to WebSocket self._log_queue: Optional[asyncio.Queue] = None + # Completion signalling for run_and_wait(). Polling `status` is unreliable + # because stop() also resets it to "idle", and `self.process` gets replaced + # by any concurrent start(), so waiters need an explicit event instead. + self._done: asyncio.Event = asyncio.Event() + self.last_exit_code: Optional[int] = None @property def logs(self) -> List[LogEntry]: @@ -54,6 +60,43 @@ class CrawlerManager: self._log_queue = asyncio.Queue() return self._log_queue + def is_busy(self) -> bool: + """Whether a crawler process is currently alive. + + This is the authoritative busy check -- `status` is a lagging indicator + that manual stop() also resets. + """ + return self.process is not None and self.process.poll() is None + + async def run_and_wait( + self, + config: CrawlerStartRequest, + extra_args: Optional[List[str]] = None, + timeout: Optional[float] = None, + ) -> int: + """Start a crawler run and block until it exits, returning the exit code. + + Used by the monitor scheduler. Returns a negative value if the run was + killed by `timeout` or if the process could not be started at all. + """ + started = await self.start(config, extra_args=extra_args) + if not started: + return -1 + + # Capture the process we just launched: a concurrent start() would + # replace self.process, so poll this reference rather than the attribute. + proc = self.process + if proc is None: + return -1 + + try: + await asyncio.wait_for(self._done.wait(), timeout=timeout) + except asyncio.TimeoutError: + await self.stop() + return -1 + + return self.last_exit_code if self.last_exit_code is not None else -1 + def _create_log_entry(self, message: str, level: str = "info") -> LogEntry: """Create log entry""" self._log_id += 1 @@ -90,7 +133,11 @@ class CrawlerManager: return "debug" return "info" - async def start(self, config: CrawlerStartRequest) -> bool: + async def start( + self, + config: CrawlerStartRequest, + extra_args: Optional[List[str]] = None, + ) -> bool: """Start crawler process""" async with self._lock: if self.process and self.process.poll() is None: @@ -99,6 +146,9 @@ class CrawlerManager: # Clear old logs self._logs = [] self._log_id = 0 + # Reset completion signalling for this run + self._done.clear() + self.last_exit_code = None # Clear pending queue (don't replace object to avoid WebSocket broadcast coroutine holding old queue reference) if self._log_queue is None: @@ -111,7 +161,7 @@ class CrawlerManager: pass # Build command line arguments - cmd = self._build_command(config) + cmd = self._build_command(config, extra_args=extra_args) # Log start information entry = self._create_log_entry(f"Starting crawler: {' '.join(cmd)}", "info") @@ -202,9 +252,13 @@ class CrawlerManager: "error_message": None } - def _build_command(self, config: CrawlerStartRequest) -> list: + def _build_command( + self, + config: CrawlerStartRequest, + extra_args: Optional[List[str]] = None, + ) -> list: """Build main.py command line arguments""" - cmd = ["uv", "run", "python", "main.py"] + cmd = [*resolve_python_cmd(), "main.py"] cmd.extend(["--platform", config.platform.value]) cmd.extend(["--lt", config.login_type.value]) @@ -232,22 +286,56 @@ class CrawlerManager: if config.max_comments_count is not None: cmd.extend(["--max_comments_count_singlenotes", str(config.max_comments_count)]) - if config.cookies: + # Each of these is only appended when explicitly set, so manual runs from + # the Crawl tab keep exactly their previous behaviour. + if config.save_data_path: + cmd.extend(["--save_data_path", config.save_data_path]) + if config.enable_cdp_mode is not None: + cmd.extend(["--enable_cdp_mode", "true" if config.enable_cdp_mode else "false"]) + if config.inject_all_cookies is not None: + cmd.extend(["--inject_all_cookies", "true" if config.inject_all_cookies else "false"]) + if config.save_login_state is not None: + cmd.extend(["--save_login_state", "true" if config.save_login_state else "false"]) + if config.max_concurrency_num is not None: + cmd.extend(["--max_concurrency_num", str(config.max_concurrency_num)]) + if config.crawler_max_sleep_sec is not None: + cmd.extend(["--crawler_max_sleep_sec", str(config.crawler_max_sleep_sec)]) + if config.enable_ip_proxy is not None: + cmd.extend(["--enable_ip_proxy", "true" if config.enable_ip_proxy else "false"]) + if config.ip_proxy_pool_count is not None: + cmd.extend(["--ip_proxy_pool_count", str(config.ip_proxy_pool_count)]) + if config.ip_proxy_provider_name: + cmd.extend(["--ip_proxy_provider_name", config.ip_proxy_provider_name]) + if config.static_proxy_url: + cmd.extend(["--static_proxy_url", config.static_proxy_url]) + + # Prefer a cookie file over passing the cookie on the command line, where + # it would be visible in the process list. + if config.cookies_file: + cmd.extend(["--cookies_file", config.cookies_file]) + elif config.cookies: cmd.extend(["--cookies", config.cookies]) cmd.extend(["--headless", "true" if config.headless else "false"]) + if extra_args: + cmd.extend(extra_args) + return cmd async def _read_output(self): """Asynchronously read process output""" loop = asyncio.get_event_loop() + # Capture the process this reader was started for. self.process can be + # replaced by a subsequent start(), which would otherwise make us read + # the exit code of the wrong run. + proc = self.process try: - while self.process and self.process.poll() is None: + while proc and proc.poll() is None: # Read a line in thread pool line = await loop.run_in_executor( - None, self.process.stdout.readline + None, proc.stdout.readline ) if line: line = line.strip() @@ -257,9 +345,9 @@ class CrawlerManager: await self._push_log(entry) # Read remaining output - if self.process and self.process.stdout: + if proc and proc.stdout: remaining = await loop.run_in_executor( - None, self.process.stdout.read + None, proc.stdout.read ) if remaining: for line in remaining.strip().split('\n'): @@ -270,7 +358,7 @@ class CrawlerManager: # Process ended if self.status == "running": - exit_code = self.process.returncode if self.process else -1 + exit_code = proc.returncode if proc else -1 if exit_code == 0: entry = self._create_log_entry("Crawler completed successfully", "success") else: @@ -283,6 +371,11 @@ class CrawlerManager: except Exception as e: entry = self._create_log_entry(f"Error reading output: {str(e)}", "error") await self._push_log(entry) + finally: + # Record the exit code and wake any run_and_wait() waiter. Runs in a + # finally so a cancelled read task still releases the waiter. + self.last_exit_code = proc.returncode if proc else None + self._done.set() # Global singleton diff --git a/api/services/interpreter.py b/api/services/interpreter.py new file mode 100644 index 0000000..e4d569c --- /dev/null +++ b/api/services/interpreter.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/services/interpreter.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Interpreter resolution for spawning crawler subprocesses. + +Historically both the crawler manager and the environment check hardcoded +``uv run``. ``uv`` is not guaranteed to be installed, so resolve the command +prefix in one place: prefer ``uv`` (matching upstream docs), fall back to a +project-local virtualenv, and finally to the interpreter running the server. +""" + +import shutil +import sys +from pathlib import Path + +# Project root: api/services/interpreter.py -> services -> api -> repo root +PROJECT_ROOT = Path(__file__).parent.parent.parent + + +def venv_python_path(project_root: Path | None = None) -> Path: + """Return the path to the project venv's Python executable.""" + root = project_root if project_root is not None else PROJECT_ROOT + if sys.platform == "win32": + return root / ".venv" / "Scripts" / "python.exe" + return root / ".venv" / "bin" / "python" + + +def resolve_python_cmd(project_root: Path | None = None) -> list[str]: + """Resolve the command prefix used to run ``main.py``. + + Order of preference: + 1. ``uv`` if it is on PATH -- matches the upstream documented workflow. + 2. The project-local ``.venv`` if it exists. + 3. The interpreter currently running the API server. + + Returns a list because the caller appends ``main.py`` and its flags. + """ + if shutil.which("uv"): + return ["uv", "run", "python"] + + venv_python = venv_python_path(project_root) + if venv_python.exists(): + return [str(venv_python)] + + return [sys.executable] + + +def describe_interpreter(project_root: Path | None = None) -> str: + """Human-readable description of what resolve_python_cmd() picks.""" + cmd = resolve_python_cmd(project_root) + if cmd[0] == "uv": + return "uv run python" + if cmd[0] == sys.executable: + return f"current interpreter ({sys.executable})" + return f"project virtualenv ({cmd[0]})" diff --git a/cmd_arg/arg.py b/cmd_arg/arg.py index 8ea133d..cf6fd7f 100644 --- a/cmd_arg/arg.py +++ b/cmd_arg/arg.py @@ -300,6 +300,14 @@ async def parse_cmd(argv: Optional[Sequence[str]] = None): rich_help_panel="Performance Configuration", ), ] = config.MAX_CONCURRENCY_NUM, + crawler_max_sleep_sec: Annotated[ + int, + typer.Option( + "--crawler_max_sleep_sec", + help="Seconds to wait between requests. Higher is slower but far less likely to trip platform rate limiting", + rich_help_panel="Performance Configuration", + ), + ] = config.CRAWLER_MAX_SLEEP_SEC, save_data_path: Annotated[ str, typer.Option( @@ -308,6 +316,41 @@ async def parse_cmd(argv: Optional[Sequence[str]] = None): rich_help_panel="Storage Configuration", ), ] = config.SAVE_DATA_PATH, + enable_cdp_mode: Annotated[ + str, + typer.Option( + "--enable_cdp_mode", + help="Whether to drive the user's local Chrome over CDP instead of launching a browser, supports yes/true/t/y/1 or no/false/f/n/0. Set to false for unattended/server runs", + rich_help_panel="Runtime Configuration", + show_default=True, + ), + ] = str(config.ENABLE_CDP_MODE), + save_login_state: Annotated[ + str, + typer.Option( + "--save_login_state", + help="Whether to persist the browser profile so a previous login can be reused, supports yes/true/t/y/1 or no/false/f/n/0", + rich_help_panel="Runtime Configuration", + show_default=True, + ), + ] = str(config.SAVE_LOGIN_STATE), + inject_all_cookies: Annotated[ + str, + typer.Option( + "--inject_all_cookies", + help="Whether to inject every cookie supplied via --cookies/--cookies_file instead of only web_session, supports yes/true/t/y/1 or no/false/f/n/0", + rich_help_panel="Runtime Configuration", + show_default=True, + ), + ] = str(config.INJECT_ALL_COOKIES), + cookies_file: Annotated[ + str, + typer.Option( + "--cookies_file", + help="Path to a file holding the cookie string. Preferred over --cookies, whose value is visible in the process list", + rich_help_panel="Runtime Configuration", + ), + ] = "", enable_ip_proxy: Annotated[ str, typer.Option( @@ -350,6 +393,18 @@ async def parse_cmd(argv: Optional[Sequence[str]] = None): enable_headless = _to_bool(headless) enable_ip_proxy_value = _to_bool(enable_ip_proxy) init_db_value = init_db.value if init_db else None + enable_cdp_mode_value = _to_bool(enable_cdp_mode) + save_login_state_value = _to_bool(save_login_state) + inject_all_cookies_value = _to_bool(inject_all_cookies) + + # A file is preferred over --cookies: a literal value on the command line + # is visible to any other user on the machine via the process list. + if cookies_file: + try: + with open(cookies_file, "r", encoding="utf-8") as f: + cookies = f.read().strip() + except OSError as e: + raise typer.BadParameter(f"Unable to read --cookies_file: {e}") # Parse specified_id and creator_id into lists specified_id_list = [id.strip() for id in specified_id.split(",") if id.strip()] if specified_id else [] @@ -368,9 +423,13 @@ async def parse_cmd(argv: Optional[Sequence[str]] = None): config.CDP_HEADLESS = enable_headless config.SAVE_DATA_OPTION = save_data_option.value config.COOKIES = cookies + config.ENABLE_CDP_MODE = enable_cdp_mode_value + config.SAVE_LOGIN_STATE = save_login_state_value + config.INJECT_ALL_COOKIES = inject_all_cookies_value config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES = max_comments_count_singlenotes config.CRAWLER_MAX_NOTES_COUNT = crawler_max_notes_count config.MAX_CONCURRENCY_NUM = max_concurrency_num + config.CRAWLER_MAX_SLEEP_SEC = crawler_max_sleep_sec config.SAVE_DATA_PATH = save_data_path config.ENABLE_IP_PROXY = enable_ip_proxy_value config.IP_PROXY_POOL_COUNT = ip_proxy_pool_count diff --git a/config/base_config.py b/config/base_config.py index cf6823c..0c8c06c 100644 --- a/config/base_config.py +++ b/config/base_config.py @@ -52,6 +52,12 @@ HEADLESS = False # Whether to save login status SAVE_LOGIN_STATE = True +# 是否注入完整 cookie(默认 False,保持上游原有行为)。 +# False 时 login_by_cookies 只写入 web_session;a1 / webId 等签名所需 cookie 只能靠 +# browser_data 下的持久化 profile 补齐。无人值守场景(服务器上跑定时监控)应设为 True, +# 否则冷 profile 下 API 签名失败,且表现为「退出码 0 但抓到 0 条」的静默失败。 +INJECT_ALL_COOKIES = False + # ==================== CDP (Chrome DevTools Protocol) 配置 ==================== # 是否启用 CDP 模式 - 使用用户本地的 Chrome/Edge 浏览器进行爬取,具有更好的反检测能力 # 开启后,会自动检测并启动用户的 Chrome/Edge 浏览器,通过 CDP 协议进行控制 diff --git a/docs/监控功能使用说明.md b/docs/监控功能使用说明.md new file mode 100644 index 0000000..dfec3fa --- /dev/null +++ b/docs/监控功能使用说明.md @@ -0,0 +1,476 @@ +# 小红书监控功能使用说明 + +> 定时重复采集一批博主或笔记,与上一轮快照对比,产出**新增作品 / 新增评论 / 点赞收藏评论数涨跌**。 + +本功能是在 MediaCrawler 之上新增的一层,代码集中在 `api/monitor/`,不侵入原有的 +`media_platform/`、`store/` 等目录。 + +--- + +## 一、为什么需要单独一层 + +原项目是**一次性采集**:跑完即退出,没有调度、没有历史、没有差分。直接复用会遇到三个硬伤: + +1. **指标会被覆盖**。`store/xhs/_store_impl.py::XhsDbStoreImplement.update_content()` 对已存在的笔记执行 + `UPDATE ... SET liked_count = ...`,历史值直接丢失。跑第二遍根本看不出"点赞从 100 涨到了 500"。 +2. **单进程串行**。`api/services/crawler_manager.py` 是全局单例,同一时刻只能跑一个 `main.py` 子进程。 +3. **运行输出无法区分**。`AsyncFileWriter` 的文件名只带日期(`creator_contents_2026-10-07.jsonl`), + 同一天多次运行会追加进同一个文件。 + +监控层为此做了对应处理:独立的快照库(保留历史)、调度器与手动采集互斥排队、以及**每轮采集写入独立目录** +(复用早已存在、但 API 层从未转发的 `--save_data_path` 参数)。 + +--- + +## 二、快速开始 + +### 1. 准备环境 + +```bash +# 依赖(若未安装 uv,本项目的解释器探测会自动回退到 .venv) +python -m venv .venv +.venv/Scripts/python -m pip install -r requirements.txt +.venv/Scripts/python -m playwright install chromium # 非 CDP 模式必需 + +# 前端 +cd webui && npm install && npm run build +``` + +### 2. 启动 + +```bash +.venv/Scripts/python -m api.main # 或 uvicorn api.main:app --port 8080 +``` + +打开 ,右上角切换到「监控」。 + +> 解释器探测顺序:`uv`(若在 PATH)→ 项目 `.venv` → 当前解释器。 +> `/api/env/check` 使用同一套逻辑,不会出现"检测失败但其实能跑"的情况。 + +### 3. 配置登录态(**无人值守的前提**) + +在监控页左下角「小红书登录态」粘贴 Cookie。定时监控不能每次都扫码,必须持久化登录态。 + +> **强烈建议先手动扫码登录一次**,以播种 `browser_data/xhs_user_data_dir`, +> 之后再粘贴 Cookie 才可靠。原因见下方「限制」。 + +### 4. 新建监控任务 + +- **类型** + - `博主`:监控其作品,填博主主页链接或纯 ID + - `笔记`:批量监控指定内容,填笔记链接或纯 ID +- **目标**:每行一个。**建议只填纯 ID** —— 链接里的 `xsec_token` 会过期,纯 ID 永久有效。 +- **间隔**:最小 30 分钟。每次运行都要拉起一次浏览器并多次请求平台,间隔过短容易触发风控。 +- **每篇评论抓取条数**:默认 50。这个值直接决定能发现多少新评论,见下方限制。 + +任务创建后立即生效,也可随时点「立即运行」手动触发一轮。 + +--- + +## 二·五、报表 + +「报表」视图是**跨任务**的统计,用来回答"这批账号这段时间表现如何"。 + +**筛选**:勾选参与统计的任务(默认全选),选日期区间(或点「近 7/30/90 天」)。 + +**两类指标,含义不同,所以分列展示**: + +| 列 | 含义 | +|---|---| +| 新增作品 / 新增评论 | 该日**首次发现**的作品数 / 评论条数 | +| 点赞 Δ / 评论 Δ / 收藏 Δ / 分享 Δ | 该日**互动增量**:Σ(当日末值 − 当日之前最后一次采到的值) | + +增量的口径有两个要点: + +- **作品首次出现的那天从 0 起算**,所以新作品的全部点赞都计入其首次发现日。这样做是为了让"新作品带来了多少赞"这件事可见,而不是把它的既有数据丢掉。 +- **某天没采到某篇作品,那天的增量算 0**,不会把跨天的增长平摊到每一天。 + +底部会标明两件事:一是**哪些指标无法解析**(小红书可能返回 `"1.2万"` 这类值,解析失败的不会被当成 0 计入,否则会伪造出一个大的负增长),二是评论数受接口限制只覆盖前 N 条。 + +> 实现上聚合是在 Python 里做的,不是一条大 SQL。原因:按笔记、按天的"上一个基线值"查询是窗口操作,SQLite 表达起来很别扭,而这里的数据量很小,可读性比压榨查询计划更值钱。 + +--- + +## 二·六、企业微信通知 + +在「监控」视图左下角配置 Webhook 地址(企业微信群 → 添加群机器人 → 复制 Webhook 地址)。 + +**两个设计取舍**: + +1. **一轮只发一条汇总**,不是每条事件发一条。一次跑出 20 篇新作品时,你收到的是"新增作品 20 篇"加前 10 条标题,而不是 20 条消息。 +2. **推送失败绝不影响采集**。通知是在数据提交之后、用独立会话发送的,任何网络错误只记日志。爬虫跑成功了不会因为 webhook 挂了而被回滚。 + +**触发时机**(仅这两类): + +- 任务失败 / 疑似登录态失效 +- 发现新增作品 + +指标变化和新增评论**不会**推送(指标变化太频繁,评论量可能很大)。 + +**任务范围**:每个任务在编辑弹窗里有「推送企业微信通知」开关,**默认关闭**。这样一个 webhook 不会被一堆无关任务刷屏。 + +- 配置好地址后可以点「发测试」验证,也可以「保存前先测」。 +- 地址里的 key 等同凭据,**服务端只回传打码形式**,要换只能重新粘贴(和 Cookie 一致)。 +- 任务卡片上的 `last_notified_at`(列表接口会返回)可以回答"为什么这轮没收到推送"。 + +--- + +## 二·七、评论视图与导出 + +「评论」页默认**按作品分组**:每篇作品一个可折叠区块,**默认只展开最新的一组**, +避免打开就是一屏文字。切到「平铺」则是一条流,每条评论下方标注它属于哪篇作品 +(封面缩略图 + 标题 + 跳原文链接)。 + +顶部可按作品筛选,选项里带每篇的评论数: + +``` +全部作品 +烤面筋热量计算 (33) +孜卷热量计算 (3) +``` + +> 评论与作品的关联是后端 JOIN 出来的(`note_title` / `note_cover` / `note_url`), +> 因为评论表本身只存 `note_id`,光看 ID 没有任何可读性。 + +### 导出 + +评论页和报表页都有「导出」按钮,走浏览器下载: + +| 端点 | 内容 | +|---|---| +| `?kind=notes` | 作品表(含互动增量列) | +| `?kind=comments` | 评论(含所属作品标题) | +| `?kind=report` | 报表按天汇总 | + +- 支持 `csv` 与 `xlsx` +- **CSV 带 UTF-8 BOM**(`utf-8-sig`)—— 否则 Excel 打开中文是乱码,这是最常见的投诉 +- 下载是**页面导航**(`window.open`),带不了自定义请求头,所以导出依赖 Cookie 鉴权 —— + 这也是会话必须存在 Cookie 里的原因之一 + +--- + +## 二·八、登录与访问控制 + +面板默认要求登录 —— `/api` 下的所有接口都需要会话,只有 `/api/health`、 +`/api/auth/login`、`/api/auth/logout` 例外。静态资源(页面本身、JS/CSS)不受限制, +否则登录页自己都加载不出来。 + +### 首次启动 + +自动生成一个随机密码并**打印在启动日志里**(只打印一次): + +``` +==================================================================== + WebUI 首次启动,已生成登录密码: + + 94Shn1fMa7dV0jqF + + 请立即登录并修改。 +==================================================================== +``` + +> 刻意**不做**"打开页面让你设置密码"的流程。在局域网监听下,任何能访问到的人 +> 都能抢先设置密码成为管理员;自动生成 + 打印避免了这种抢占,也避免了把自己锁在外面。 + +### 忘记密码 + +设置环境变量 `MC_PASSWORD` 后重启即可: + +```bash +MC_PASSWORD=我的新密码 # Linux/macOS +set MC_PASSWORD=我的新密码 # Windows cmd +``` + +该变量**优先级始终高于**数据库里的密码,且**不会被写入磁盘**。登录后到设置页改成正式密码即可。 + +### 环境变量 + +配置写在项目根目录的 `.env`(已被 gitignore)。 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `MC_HOST` | `127.0.0.1` | 监听地址。**要局域网访问须设为 `0.0.0.0`** | +| `MC_PORT` | `8080` | 端口 | +| `MC_PASSWORD` | 空 | 覆盖数据库密码,忘记密码时的恢复通道 | +| `MC_COOKIE_SECURE` | 关 | **面板走 HTTPS 时才开**。局域网明文下开启会导致浏览器丢弃 Cookie,表现为**登录页反复刷新且无任何报错** | +| `MC_SESSION_TTL_HOURS` | `336` | 登录有效期(14 天) | +| `MC_TRUST_PROXY` | 关 | 仅在受信任的反向代理之后开启,否则 `X-Forwarded-For` 可被伪造以绕过登录节流 | +| `MC_CORS_ORIGINS` | 空 | 附加的允许来源,逗号分隔 | +| `MC_CORS_ORIGIN_REGEX` | 空 | 允许来源的正则,用于局域网里的 Vite 开发服务器 | + +> 本项目的 `.env` 此前**从未被加载过**(代码里没有任何 `load_dotenv` 调用,尽管 +> `python-dotenv` 一直是依赖、`.env.example` 也一直在仓库里)。现已修复。 + +### 安全边界(请务必了解) + +- **局域网是明文 HTTP**,所以 Cookie 没开 `Secure`,`SameSite=Lax`。 + 这意味着**同网段抓包能看到会话令牌**。安全边界是"内网 + 密码",不是传输加密。 +- **超出可信网络之外请走 HTTPS 反向代理**,不要把本服务直接暴露到公网。 +- **登录节流是进程内的**:重启即清零。单用户单 worker 场景足够; + 若日后多 worker,节流会按 worker 各算各的。反向代理下 `request.client.host` 是代理地址, + 需配合 `MC_TRUST_PROXY` 才能正确识别来源。 +- `/docs`、`/redoc`、`/openapi.json` **已关闭** —— 它们默认不鉴权,等于免费公开整个 API 地图。 + +### 会话与登出 + +- 会话存在服务端(`auth_session` 表),库里只存令牌的 **SHA-256**,不存令牌本身 +- 退出登录、**修改密码**都会立即失效(改密码会踢掉所有设备,并给当前设备补发一个新会话) +- 令牌可放在 Cookie(浏览器自动携带,WebSocket 与文件下载都依赖它) + 或 `Authorization: Bearer`(方便脚本调用) + +--- + +## 二·九、设置页 + +原先挤在监控页左下角的 Cookie 与 Webhook 面板已迁到这里,并补齐了采集策略、代理与账号安全。 + +### 分区与生效方式 + +| 分区 | 内容 | 生效时机 | +|---|---|---| +| 登录态 | 小红书 Cookie | 下一轮采集 | +| 通知 | 企业微信 Webhook | 下一条推送 | +| 采集策略 | 新任务默认间隔、默认单轮上限、默认评论条数 | **仅影响新建任务** | +| 采集策略 | 请求间隔、抓二级评论 | 下一轮采集 | +| 采集策略 | 活跃时段 | 定时任务的下一次触发 | +| 代理 | 开关、提供方、池大小、静态地址 | 下一轮采集 | +| 账号安全 | 修改密码 | 立即(其他设备全部掉线) | + +**活跃时段**:只在此时段内触发定时采集,窗口外任务保持到期状态、不会丢失, +窗口一开照常执行。默认 `0–23` 即全天;也支持跨午夜(如 `22–6`)。 + +**"仅影响新建任务"** 的那几项是刻意的:改了默认间隔不应该把已有任务的间隔一起改掉。 + +### 设计要点 + +- **敏感值永不回传**:Cookie 和 Webhook 的 `GET` 只返回「是否已配置」与长度,不返回值。 + 表单不会把没动过的敏感项覆盖掉。 +- **部分更新**:只有请求里出现的 key 会被写入。表单一角改动不会清空其他设置。 +- **设置项由后端声明**:`api/monitor/app_settings.py` 里的注册表(类型、范围、选项、默认值) + 是唯一事实来源,前端**按它生成表单**。加一个设置项不需要改前端字段清单。 +- **越界即拒绝**:超出范围、未知的 key、非法的枚举值都返回 400 而不是静默接受。 + +> 「扫码登录」入口**尚未实现**。它需要跑起爬虫子进程、捕获二维码并实时推流, +> 属于一个独立功能而非设置项,这里不做一个半成品。 + +--- + +## 二·十、平台切换与能力矩阵 + +**右上角的下拉框统一切换平台**,「采集 / 监控 / 报表 / 设置」全部跟着变。选择会记住, +刷新后不会跳回小红书。采集页原来那个平台下拉已移除,避免出现两个事实来源。 + +### 已接通 vs 未接通 + +矩阵里有两个**不同**的概念,混淆会误导: + +| 字段 | 含义 | +|---|---| +| `crawler_modes` / `metrics` / `comment_levels` / `media` | **上游爬虫模块**能做什么 | +| `monitor_wired` | **监控层**是否已接线 | + +**7 个平台的爬虫模块都实现了 search / detail / creator**,真正的差异在指标上: + +| 平台 | 指标 | 评论层级 | 媒体 | 监控接线 | +|---|---|---|---|---| +| 小红书 | 点赞 / 评论 / 收藏 / 分享 | 2 | ✅ | ✅ | +| 抖音 | 点赞 / 评论 / 收藏 / 分享(**无播放量**) | 2 | ✅ | ❌ | +| 快手 | 点赞 / 播放(无评论、分享、收藏) | 1 | ✅ | ❌ | +| B站 | 点赞 / **播放** / **弹幕** / 评论 / 收藏 / 投币 / 分享(最全) | 2 | ✅ | ❌ | +| 微博 | 点赞 / 评论 / 转发(无收藏) | 2 | ✅ | ❌ | +| 贴吧 | 仅回复数 | 2 | ❌ | ❌ | +| 知乎 | 赞同 / 评论 | 2 | ❌ | ❌ | + +> **要更正一个常见误解**:这个代码库里**抖音不存播放量**(只映射点赞/收藏/评论/分享)。 +> 有播放量的是 **B 站**,它还有弹幕。 + +未接通的平台**可以选,但各页会显示明确的说明面板**,并且**创建任务会被直接拒绝**: + +``` +400 抖音的爬虫已支持,但监控层尚未接通,暂时无法创建监控任务。 +``` + +而不是接受任务、然后让它永远跑不出数据 —— 那正是之前"博主主页解析失败被误报成登录失效"的同一种静默故障。 + +### 设置的两层 + +| 位置 | 范围 | 内容 | +|---|---|---| +| 左侧导航「设置」 | **按平台** | 登录 Cookie、采集策略、代理 | +| 右上角「系统设置」 | **全局** | 通知、活跃时段、账号安全 | + +**这不是随便分的**:企业微信只有一个群、调度器只有一套时段规则、密码只有一份 —— +把它们放进"小红书专属"的页面里,会让人以为它们是按平台存的。 + +存储上键名带作用域前缀:`platform.<平台>.<项>` 与 `system.<项>`。 +**旧键会在启动时自动迁移**(`xhs_cookie` → `platform.xhs.cookie`), +且是幂等的:新键已存在时以新键为准,不会覆盖你后来改的值。 + +--- + +## 三、必须知道的限制 + +### 1. 「新增评论」是近似值 —— 最重要的一条 + +小红书评论接口 `/api/sns/web/v2/comment/page` **没有排序参数**,只能拿到平台默认排序(热评优先)的 +前 N 条。因此: + +- 我们只能"每次抓前 N 条做差集",**新发布但沉底的评论不会被发现** +- N 调大能提高发现率,但请求量线性增长,风控风险上升 +- 评论事件区分两种,UI 上也分别标注: + - `new_comment_posted`(新评论):`create_time` 晚于上一轮开始时间,是真·新发布 + - `new_comment_seen`(新出现评论):只是本轮才进入可见窗口的历史评论 + +**这条限制无法通过调参绕过**,是该接口的固有限制。 + +### 2. Cookie 失效是「静默失败」 + +`login_by_cookies()` 只注入 `web_session`,而 API 签名还需要 `a1` / `webId` 等; +更麻烦的是**cookie 登录不做任何校验** —— 坏 Cookie 不会让进程报错退出,而是 +**退出码 0、抓到 0 条**。 + +监控层因此把「退出码 0 且 0 条作品」判定为 `suspected_auth_failure` 并在 UI 上标红, +而不是当成"该博主没发新作品"。这是无人值守场景最容易误报的地方。 + +本实现额外做了两件事: +- 通过 `--inject_all_cookies` 注入**完整** Cookie(默认关闭,保持上游行为不变) +- 通过 `--cookies_file` 传 Cookie,避免明文出现在进程列表里 + +### 3. 作品窗口被截断 + +`每轮最多采集作品数`(默认 20)限定了"该博主的作品"到底指多少条。 +UI 会把该上限显示在作品表旁,避免误以为看到了全部。 + +### 4. 昵称与用户 ID 已被上游脱敏 + +`store/xhs/__init__.py` 落库前调用 `mask_nickname()` 与 `anonymize_user_id()`, +存储的是**打码昵称**与哈希后的 `creator_hash`,没有真实昵称和 user_id。 +这是上游的隐私保护设计,监控层未做改动。 + +### 5. 不发「笔记被删」事件 + +`creator` 模式只取前 N 条,笔记"消失"多半只是掉出窗口;`detail` 模式遇到 +`xsec_token` 过期也会失败。二者与"真被删"无法区分,因此不产生删除事件, +改为在作品表里展示 `last_seen_at`。 + +### 6. 定时任务与手动采集互斥 + +二者共用同一个爬虫子进程。监控任务运行期间点「采集」会被拒绝(返回 400); +反之若有手动采集在跑,到期的监控任务会**保持到期状态排队**,不会丢失,空闲后自动补上。 + +--- + +## 四、数据存放 + +| 内容 | 位置 | +|---|---| +| 监控库(任务/快照/事件/评论/设置/会话) | **MySQL**,库名由 `MYSQL_DB_NAME` 指定 | +| 每轮原始 jsonl | `data/monitor_runs/{task_id}/{run_id}/{platform}/jsonl/` | + +> 爬虫每轮的原始产出**仍然写独立 jsonl 目录**,不进 MySQL。 +> 这是差分机制的基础:每轮写在单独目录里,才能算出"这轮新增了什么"。 +> 多轮数据混在同一批表里的话,这个判断就做不到了。 + +### MySQL 配置与安全边界 + +连接信息写在 `.env`(已被 gitignore,不会进版本库): + +```ini +MYSQL_DB_HOST=<数据库地址> +MYSQL_DB_PORT=3306 +MYSQL_DB_USER=<账号> +MYSQL_DB_PWD=<密码> +MYSQL_DB_NAME=mediacrawler +``` + +> 真实凭据只写在 `.env` 里(已被 gitignore),**不要写进这个文档或任何会提交的文件**。 + +**"只操作这个库"由两层保证,缺一不可**: + +1. **数据库授权(真正的保证)**。账号应只被授予目标库的权限: + + ```sql + REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'MediaCrawler'@'%'; + GRANT ALL PRIVILEGES ON `mediacrawler`.* TO 'MediaCrawler'@'%'; + FLUSH PRIVILEGES; + ``` + + 这样该账号 `SHOW DATABASES` 只能看到目标库,**代码就算写错也碰不到别的库**。 + +2. **启动自检(防配置写错)**。应用启动时会执行 `SELECT DATABASE()`, + 与 `MYSQL_DB_NAME` 不符就**拒绝启动**,而不是往错误的库里写。 + +**字符集**:这台服务的服务端和库默认都是 `latin1`。代码在建表时**逐表强制 +`utf8mb4`**,不依赖库默认值 —— 否则中文会被拒或变成问号。 + +**连接保活**:MySQL 默认 8 小时断开空闲连接,而监控服务是常驻的。 +已配置 `pool_recycle=3600` + `pool_pre_ping`,避免"server has gone away"。 + +**表引擎**:全部 InnoDB(`monitor_run.exit_code` 用 `BIGINT` —— Windows 的退出码是 +无符号 32 位,`0xC0000142` 会溢出有符号 `INT`)。 + +### 从 SQLite 迁移(如有旧数据) + +```bash +python -m api.monitor.migrate_from_sqlite --dry-run # 先看要迁什么 +python -m api.monitor.migrate_from_sqlite # 正式迁移 +``` + +保留原主键(否则 `task_id` 关联会错位);目标库非空时会拒绝执行,除非加 `--force`。 + +监控库中的 Cookie 为明文存储,这是当前版本的已知取舍。 + +--- + +## 五、API + +所有操作都有对应的 HTTP 接口,UI 只是其中一层封装: + +``` +GET /api/monitor/overview 看板汇总 +GET /api/monitor/tasks 任务列表 +POST /api/monitor/tasks 新建任务 +PATCH /api/monitor/tasks/{id} 修改 +DELETE /api/monitor/tasks/{id} 删除 +POST /api/monitor/tasks/{id}/run 立即运行(后台执行,立即返回) +GET /api/monitor/tasks/{id}/runs 运行历史 +GET /api/monitor/notes 作品表(含与上一轮的 Δ) +GET /api/monitor/notes/{id}/series 单篇指标时间序列 +GET /api/monitor/comments 评论流(带所属作品;?note_id= 筛选,?group_by=note 按作品分组) +GET /api/monitor/comment-notes 有评论的作品及其条数(评论筛选下拉用) +GET /api/monitor/export 导出(?kind=notes|comments|report&format=csv|xlsx) +GET /api/monitor/events 变化事件流 +POST /api/monitor/events/read 标记已读 +GET /api/monitor/cookie 登录态健康度(**不返回 Cookie 值**) +POST /api/monitor/cookie 保存 Cookie +DELETE /api/monitor/cookie 清除 Cookie + +GET /api/config/platforms 平台能力矩阵(含 monitor_wired,前端据此渲染切换器) +GET /api/settings 设置 + 表单描述(?platform=,敏感值只回状态) +PUT /api/settings 部分更新(只写请求里出现的 key) + +GET /api/auth/me 身份探测(401 即未登录) +POST /api/auth/login 登录(发 HttpOnly Cookie) +POST /api/auth/logout 退出 +POST /api/auth/password 修改密码(踢掉所有其他设备) + +GET /api/monitor/report 报表(?task_id=1&task_id=2&start_date=&end_date=) +GET /api/monitor/webhook 通知配置状态(**只返回打码地址**) +POST /api/monitor/webhook 保存 Webhook 地址 +DELETE /api/monitor/webhook 删除 Webhook +POST /api/monitor/webhook/test 发送测试消息 +``` + +> `task_id` 用**重复参数**而非逗号拼接(`?task_id=1&task_id=2`);不传表示统计全部任务。 + +--- + +## 六、故障排查 + +| 现象 | 原因 / 处理 | +|---|---| +| 任务一直不运行 | 未配置 Cookie(调度器会跳过并保持任务到期);或全局已有采集在跑 | +| 任务标红「疑似登录态失效」 | Cookie 过期。重新粘贴;若反复失败,先手动扫码登录一次播种浏览器 profile | +| 抓到的作品数长期为 0 | 同上;也可能是该博主确实没有作品 | +| 发现不了新评论 | 评论接口无时间排序所致,调大「每篇评论抓取条数」可缓解但无法根治 | +| 首轮没有任何"新增"事件 | 刻意设计:首轮建立基线,全部数据视为已有,不产生变化事件 | diff --git a/media_platform/xhs/core.py b/media_platform/xhs/core.py index 8110907..080daba 100644 --- a/media_platform/xhs/core.py +++ b/media_platform/xhs/core.py @@ -201,9 +201,22 @@ class XiaoHongShuCrawler(AbstractCrawler): # Parse creator URL to get user_id and security tokens creator_info: CreatorUrlInfo = parse_creator_info_from_url(creator_url) utils.logger.info(f"[XiaoHongShuCrawler.get_creators_and_notes] Parse creator URL info: {creator_info}") - user_id = creator_info.user_id + except ValueError as e: + utils.logger.error(f"[XiaoHongShuCrawler.get_creators_and_notes] Failed to parse creator URL: {e}") + continue - # get creator detail info from web html content + user_id = creator_info.user_id + + # Fetching the profile page is best-effort and must not abort the run. + # It only feeds save_creator(), which is a no-op in this build, while + # the notes themselves come from a completely different endpoint. + # Scraping the profile means parsing window.__INITIAL_STATE__ out of + # HTML, which fails whenever the platform serves a different page -- + # a JSONDecodeError there is especially misleading because it is a + # ValueError subclass, so it used to be reported as "failed to parse + # creator URL" and then skipped the creator entirely, yielding zero + # notes for a perfectly valid target. + try: createor_info: Dict = await self.xhs_client.get_creator_info( user_id=user_id, xsec_token=creator_info.xsec_token, @@ -211,9 +224,6 @@ class XiaoHongShuCrawler(AbstractCrawler): ) if createor_info: await xhs_store.save_creator(user_id, creator=createor_info) - except ValueError as e: - utils.logger.error(f"[XiaoHongShuCrawler.get_creators_and_notes] Failed to parse creator URL: {e}") - continue except (IPBlockError, PlatformAccessError) as e: # Access restricted on the creator homepage, skip this creator instead of crashing the run. utils.logger.error( @@ -221,6 +231,11 @@ class XiaoHongShuCrawler(AbstractCrawler): f"建议降低采集频率、更换 IP 或检查账号状态" ) continue + except Exception as e: + utils.logger.warning( + f"[XiaoHongShuCrawler.get_creators_and_notes] Could not fetch profile for {user_id} " + f"({type(e).__name__}: {e}); continuing to fetch the creator's notes anyway" + ) # Use fixed crawling interval crawl_interval = config.CRAWLER_MAX_SLEEP_SEC diff --git a/media_platform/xhs/login.py b/media_platform/xhs/login.py index e382954..ba6b2a7 100644 --- a/media_platform/xhs/login.py +++ b/media_platform/xhs/login.py @@ -213,8 +213,12 @@ class XiaoHongShuLogin(AbstractLogin): async def login_by_cookies(self): """login xiaohongshu website by cookies""" utils.logger.info("[XiaoHongShuLogin.login_by_cookies] Begin login xiaohongshu by cookie ...") + injected = 0 for key, value in utils.convert_str_cookie_to_dict(self.cookie_str).items(): - if key != "web_session": # Only set web_session cookie attribute + # Default (upstream) behaviour injects only web_session. Unattended runs + # need a1 / webId as well, otherwise signed API calls fail and the run + # exits 0 having fetched nothing -- a silent failure. + if not config.INJECT_ALL_COOKIES and key != "web_session": continue await self.browser_context.add_cookies([{ 'name': key, @@ -222,3 +226,8 @@ class XiaoHongShuLogin(AbstractLogin): 'domain': ".rednote.com" if config.XHS_INTERNATIONAL else ".xiaohongshu.com", 'path': "/" }]) + injected += 1 + utils.logger.info( + f"[XiaoHongShuLogin.login_by_cookies] Injected {injected} cookie(s), " + f"inject_all_cookies={config.INJECT_ALL_COOKIES}" + ) diff --git a/requirements.txt b/requirements.txt index 747cce7..4309a3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,4 +28,8 @@ motor>=3.3.0 openpyxl>=3.1.2 pytest>=7.4.0 pytest-asyncio>=0.21.0 -xhshow>=0.2.0 \ No newline at end of file +xhshow>=0.2.0 +# Required by uvicorn to handle WebSocket upgrades. Declared in pyproject.toml +# but previously missing here, so installing from this file left the live log +# stream silently non-functional (uvicorn answers every upgrade with 404). +websockets>=15.0.1 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index dc593a4..2c21f5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,3 +96,32 @@ def sample_xhs_creator(): "interaction": 50000, "tag_list": '{"profession": "Designer", "interest": "Photography"}' } + + +@pytest.fixture(autouse=True) +def _bypass_auth_for_non_auth_suites(request): + """Skip API authentication for suites that are not about authentication. + + Adding auth to every /api route breaks any test that speaks HTTP, so those + suites override the dependency here. This uses FastAPI's own + ``dependency_overrides`` mechanism rather than a production-visible + "test mode" switch, which could be shipped enabled by accident. + + ``tests/test_auth.py`` is deliberately excluded: it must exercise the real + enforcement path, including the route-enumeration guard that asserts every + other /api route really does return 401. + """ + if request.node.fspath.basename == "test_auth.py": + yield + return + + from api.auth import require_auth, require_ws_auth + from api.main import app + + app.dependency_overrides[require_auth] = lambda: None + app.dependency_overrides[require_ws_auth] = lambda: None + try: + yield + finally: + app.dependency_overrides.pop(require_auth, None) + app.dependency_overrides.pop(require_ws_auth, None) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..f980875 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,503 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_auth.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Tests for WebUI authentication. + +Deliberately does NOT install ``app.dependency_overrides``: the point of this +file is to exercise the real enforcement path. Other suites override +``require_auth`` so they can keep testing their own concerns. +""" + +import asyncio +import time + +import httpx +import pytest +import pytest_asyncio +from fastapi import WebSocketException +from sqlalchemy import func, select + +from api import auth +from api.main import app +from api.monitor import db as monitor_db +from api.monitor.models import AuthSession + +PASSWORD = "correct-horse-battery" + +# Captured at import, i.e. before the autouse fixture patches the module global, +# so the guard test below checks the value that actually ships. +REAL_PBKDF2_ITERATIONS = auth.PBKDF2_ITERATIONS + +# Every /api route that is allowed to answer without a session. +EXEMPT_PATHS = {"/api/health", "/api/auth/login", "/api/auth/logout"} + + +@pytest.fixture(autouse=True) +def cheap_hashing(monkeypatch): + """600k iterations is right in production and unusable in a test suite. + + hash_password() resolves the count at call time precisely so this works. + """ + monkeypatch.setattr(auth, "PBKDF2_ITERATIONS", 1_000) + monkeypatch.delenv("MC_PASSWORD", raising=False) + auth.reset_throttle_state() + yield + auth.reset_throttle_state() + + +@pytest_asyncio.fixture +async def db(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + yield monitor_db + await monitor_db.dispose_engine() + + +@pytest_asyncio.fixture +async def client(db): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + +async def _seed_password(password: str = PASSWORD) -> None: + async with monitor_db.get_session() as session: + await auth.set_password(session, password) + + +# -------------------------------------------------------------------------- +# Password hashing +# -------------------------------------------------------------------------- + +class TestPasswordHashing: + def test_iteration_count_has_not_been_lowered(self): + """Guard: someone trimming this for speed would weaken every install.""" + assert REAL_PBKDF2_ITERATIONS >= 600_000 + + def test_round_trip(self): + stored = auth.hash_password(PASSWORD) + assert auth._verify_password_sync(PASSWORD, stored) is True + + def test_wrong_password_rejected(self): + stored = auth.hash_password(PASSWORD) + assert auth._verify_password_sync("wrong", stored) is False + + def test_same_password_hashes_differently(self): + """A fixed salt would let one rainbow table crack every install.""" + assert auth.hash_password(PASSWORD) != auth.hash_password(PASSWORD) + + def test_format_is_self_describing(self): + algo, iterations, salt, digest = auth.hash_password(PASSWORD).split("$") + assert algo == "pbkdf2_sha256" + assert int(iterations) == auth.PBKDF2_ITERATIONS + assert salt and digest + + @pytest.mark.parametrize("stored", ["", "garbage", "md5$1$a$b", "pbkdf2_sha256$x$a$b"]) + def test_malformed_stored_hash_is_rejected_not_raised(self, stored): + assert auth._verify_password_sync(PASSWORD, stored) is False + + +# -------------------------------------------------------------------------- +# Credentials +# -------------------------------------------------------------------------- + +class TestCredentials: + @pytest.mark.asyncio + async def test_check_password_against_stored_hash(self, db): + await _seed_password() + async with monitor_db.get_session() as session: + assert await auth.check_password(session, PASSWORD) is True + assert await auth.check_password(session, "nope") is False + + @pytest.mark.asyncio + async def test_no_password_configured_denies_everything(self, db): + """An unset credential must not mean "open".""" + async with monitor_db.get_session() as session: + assert await auth.check_password(session, "") is False + assert await auth.check_password(session, PASSWORD) is False + + @pytest.mark.asyncio + async def test_env_override_wins_and_is_not_persisted(self, db, monkeypatch): + """The documented way back in after forgetting the password.""" + await _seed_password("stored-password") + monkeypatch.setenv("MC_PASSWORD", "env-password") + + async with monitor_db.get_session() as session: + assert await auth.check_password(session, "env-password") is True + assert await auth.check_password(session, "stored-password") is False + + # Override must never be written to disk. + assert await auth.current_password_hash(session) != "" + assert "env-password" not in (await auth.current_password_hash(session)) + + @pytest.mark.asyncio + async def test_first_run_generates_a_credential(self, db, monkeypatch): + monkeypatch.delenv("MC_PASSWORD", raising=False) + + generated = await auth.ensure_initial_credential() + assert generated + + # Second call is a no-op. + assert await auth.ensure_initial_credential() is None + + async with monitor_db.get_session() as session: + assert await auth.check_password(session, generated) is True + + @pytest.mark.asyncio + async def test_first_run_defers_to_env_password(self, db, monkeypatch): + monkeypatch.setenv("MC_PASSWORD", "env-password") + + assert await auth.ensure_initial_credential() is None + + async with monitor_db.get_session() as session: + assert await auth.current_password_hash(session) == "" + + +# -------------------------------------------------------------------------- +# Sessions +# -------------------------------------------------------------------------- + +class TestSessions: + @pytest.mark.asyncio + async def test_round_trip(self, db): + async with monitor_db.get_session() as session: + token, expires_at = await auth.create_session(session) + + async with monitor_db.get_session() as session: + assert await auth.resolve_session(session, token) is not None + assert expires_at > 0 + + @pytest.mark.asyncio + async def test_only_the_hash_is_stored(self, db): + """A database leak must not hand over live sessions.""" + async with monitor_db.get_session() as session: + token, _ = await auth.create_session(session) + + async with monitor_db.get_session() as session: + stored = (await session.scalars(select(AuthSession.token_hash))).all() + assert token not in stored + assert auth._hash_token(token) in stored + + @pytest.mark.asyncio + async def test_expired_session_is_rejected_and_removed(self, db): + async with monitor_db.get_session() as session: + token, _ = await auth.create_session(session) + row = await session.get(AuthSession, auth._hash_token(token)) + row.expires_at = 1 # long past + + async with monitor_db.get_session() as session: + assert await auth.resolve_session(session, token) is None + + # Fresh session: the identity map in the one above still holds the + # pending-delete object, so it would answer as if the row were present. + async with monitor_db.get_session() as session: + assert await session.get(AuthSession, auth._hash_token(token)) is None + + @pytest.mark.asyncio + async def test_unknown_token_is_rejected(self, db): + async with monitor_db.get_session() as session: + assert await auth.resolve_session(session, "never-issued") is None + assert await auth.resolve_session(session, "") is None + + @pytest.mark.asyncio + async def test_logout_revokes_only_that_session(self, db): + async with monitor_db.get_session() as session: + first, _ = await auth.create_session(session) + second, _ = await auth.create_session(session) + + async with monitor_db.get_session() as session: + await auth.revoke_session(session, first) + + async with monitor_db.get_session() as session: + assert await auth.resolve_session(session, first) is None + assert await auth.resolve_session(session, second) is not None + + @pytest.mark.asyncio + async def test_revoke_all_clears_every_session(self, db): + async with monitor_db.get_session() as session: + await auth.create_session(session) + await auth.create_session(session) + + async with monitor_db.get_session() as session: + removed = await auth.revoke_all_sessions(session) + assert removed == 2 + + async with monitor_db.get_session() as session: + assert await session.scalar(select(func.count()).select_from(AuthSession)) == 0 + + +# -------------------------------------------------------------------------- +# Throttle +# -------------------------------------------------------------------------- + +class TestThrottle: + @pytest.mark.asyncio + async def test_below_threshold_is_not_throttled(self, db): + key = "1.2.3.4" + for _ in range(auth.THROTTLE_THRESHOLD - 1): + await auth.record_failure(key) + assert await auth.retry_after_seconds(key) == 0 + + @pytest.mark.asyncio + async def test_lockout_after_repeated_failures(self, db): + key = "1.2.3.4" + for _ in range(auth.THROTTLE_THRESHOLD): + await auth.record_failure(key) + assert await auth.retry_after_seconds(key) > 0 + + @pytest.mark.asyncio + async def test_success_clears_failures(self, db): + key = "1.2.3.4" + for _ in range(auth.THROTTLE_THRESHOLD): + await auth.record_failure(key) + await auth.clear_failures(key) + assert await auth.retry_after_seconds(key) == 0 + + @pytest.mark.asyncio + async def test_failures_age_out_of_the_window(self, db, monkeypatch): + """Driven by a fake clock rather than sleeping 15 minutes.""" + key = "1.2.3.4" + clock = {"now": 1000.0} + monkeypatch.setattr(auth, "_now", lambda: clock["now"]) + + for _ in range(auth.THROTTLE_THRESHOLD): + await auth.record_failure(key) + assert await auth.retry_after_seconds(key) > 0 + + clock["now"] += auth.THROTTLE_WINDOW_SECONDS + 1 + assert await auth.retry_after_seconds(key) == 0 + + @pytest.mark.asyncio + async def test_keys_are_independent(self, db): + for _ in range(auth.THROTTLE_THRESHOLD): + await auth.record_failure("attacker") + assert await auth.retry_after_seconds("attacker") > 0 + assert await auth.retry_after_seconds("innocent") == 0 + + +# -------------------------------------------------------------------------- +# HTTP enforcement — the acceptance criteria +# -------------------------------------------------------------------------- + +class TestEnforcement: + @pytest.mark.asyncio + async def test_health_is_reachable_without_a_session(self, client): + assert (await client.get("/api/health")).status_code == 200 + + @pytest.mark.asyncio + async def test_protected_endpoint_returns_401_without_a_session(self, client): + response = await client.get("/api/monitor/tasks") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_wrong_password_is_401_and_generic(self, client): + await _seed_password() + response = await client.post("/api/auth/login", json={"password": "wrong"}) + assert response.status_code == 401 + # Must not reveal whether a password is even configured. + assert response.json()["detail"] == auth.INVALID_CREDENTIALS + + @pytest.mark.asyncio + async def test_login_unlocks_the_api(self, client): + await _seed_password() + + login = await client.post("/api/auth/login", json={"password": PASSWORD}) + assert login.status_code == 200 + assert auth.SESSION_COOKIE_NAME in client.cookies + + assert (await client.get("/api/monitor/tasks")).status_code == 200 + + @pytest.mark.asyncio + async def test_cookie_is_httponly_and_lax(self, client): + await _seed_password() + login = await client.post("/api/auth/login", json={"password": PASSWORD}) + raw = login.headers["set-cookie"].lower() + assert "httponly" in raw + assert "samesite=lax" in raw + # Secure must be OFF by default: the LAN bind is plain HTTP and a Secure + # cookie is silently dropped there, looping the login page. + assert "secure" not in raw + + @pytest.mark.asyncio + async def test_bearer_token_also_works(self, client): + """Scripts and curl cannot use a cookie jar conveniently.""" + await _seed_password() + login = await client.post("/api/auth/login", json={"password": PASSWORD}) + token = login.cookies[auth.SESSION_COOKIE_NAME] + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as bare: + bare.headers["Authorization"] = f"Bearer {token}" + assert (await bare.get("/api/monitor/tasks")).status_code == 200 + + @pytest.mark.asyncio + async def test_tampered_token_is_rejected(self, client): + await _seed_password() + await client.post("/api/auth/login", json={"password": PASSWORD}) + client.cookies.set(auth.SESSION_COOKIE_NAME, "not-a-real-token") + assert (await client.get("/api/monitor/tasks")).status_code == 401 + + @pytest.mark.asyncio + async def test_logout_invalidates_the_session(self, client): + await _seed_password() + await client.post("/api/auth/login", json={"password": PASSWORD}) + assert (await client.get("/api/monitor/tasks")).status_code == 200 + + assert (await client.post("/api/auth/logout")).status_code == 200 + assert (await client.get("/api/monitor/tasks")).status_code == 401 + + @pytest.mark.asyncio + async def test_me_reports_401_when_logged_out(self, client): + await _seed_password() + assert (await client.get("/api/auth/me")).status_code == 401 + + await client.post("/api/auth/login", json={"password": PASSWORD}) + me = await client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json()["authenticated"] is True + + @pytest.mark.asyncio + async def test_password_change_evicts_other_devices(self, client): + await _seed_password() + + # A second "device" holds its own session. + login = await client.post("/api/auth/login", json={"password": PASSWORD}) + other_token = login.cookies[auth.SESSION_COOKIE_NAME] + + changed = await client.post( + "/api/auth/password", + json={"current": PASSWORD, "new": "brand-new-password"}, + ) + assert changed.status_code == 200 + + # The old token is dead. + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as other: + other.cookies.set(auth.SESSION_COOKIE_NAME, other_token) + assert (await other.get("/api/monitor/tasks")).status_code == 401 + + # ...and the caller is still logged in. + assert (await client.get("/api/monitor/tasks")).status_code == 200 + + @pytest.mark.asyncio + async def test_password_change_requires_the_current_password(self, client): + await _seed_password() + await client.post("/api/auth/login", json={"password": PASSWORD}) + + response = await client.post( + "/api/auth/password", json={"current": "wrong", "new": "whatever-new"} + ) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_repeated_failures_get_throttled(self, client): + await _seed_password() + for _ in range(auth.THROTTLE_THRESHOLD): + await client.post("/api/auth/login", json={"password": "wrong"}) + + blocked = await client.post("/api/auth/login", json={"password": PASSWORD}) + assert blocked.status_code == 429 + assert "retry-after" in {k.lower() for k in blocked.headers} + + @pytest.mark.asyncio + async def test_docs_are_not_exposed(self, client): + for path in ("/docs", "/redoc", "/openapi.json"): + assert (await client.get(path)).status_code == 404 + + +class TestEveryRouteIsGuarded: + @pytest.mark.asyncio + async def test_no_api_route_is_accidentally_open(self, client): + """The guard that stops the next endpoint from shipping unauthenticated.""" + unguarded = [] + + for route in app.routes: + path = getattr(route, "path", "") + methods = getattr(route, "methods", None) + if not path.startswith("/api") or not methods or path in EXEMPT_PATHS: + continue + + # Substitute dummy values for path params so we reach the auth check + # rather than a 404/422 on the parameter itself. + concrete = "/".join( + "1" if segment.startswith("{") else segment for segment in path.split("/") + ) + + for method in methods - {"HEAD", "OPTIONS"}: + response = await client.request(method, concrete, json={}) + if response.status_code != 401: + unguarded.append(f"{method} {path} -> {response.status_code}") + + assert not unguarded, f"以下 /api 路由未受鉴权保护:{unguarded}" + + +# -------------------------------------------------------------------------- +# WebSocket enforcement +# -------------------------------------------------------------------------- + +class _FakeWebSocket: + """Only `.cookies` is read by require_ws_auth.""" + + def __init__(self, cookies): + self.cookies = cookies + + +class TestWebSocketAuth: + """Guarding websockets needs its own mechanism: BaseHTTPMiddleware returns + early for non-http scopes, and HTTP router dependencies never run for them. + Without this the live crawl log stream would be wide open. + """ + + @pytest.mark.asyncio + async def test_missing_cookie_is_rejected(self, db): + with pytest.raises(WebSocketException) as excinfo: + await auth.require_ws_auth(_FakeWebSocket({})) + assert excinfo.value.code == 1008 + + @pytest.mark.asyncio + async def test_valid_cookie_is_accepted(self, db): + async with monitor_db.get_session() as session: + token, _ = await auth.create_session(session) + + # No exception means accepted. + await auth.require_ws_auth(_FakeWebSocket({auth.SESSION_COOKIE_NAME: token})) + + @pytest.mark.asyncio + async def test_unknown_cookie_is_rejected(self, db): + with pytest.raises(WebSocketException): + await auth.require_ws_auth(_FakeWebSocket({auth.SESSION_COOKIE_NAME: "bogus"})) + + def test_every_websocket_route_carries_the_guard(self): + guarded = { + route.path + for route in app.routes + if route.__class__.__name__ == "APIWebSocketRoute" + and any( + getattr(dep.dependency, "__name__", "") == "require_ws_auth" + for dep in (route.dependencies or []) + ) + } + every_ws = { + route.path + for route in app.routes + if route.__class__.__name__ == "APIWebSocketRoute" + } + assert every_ws, "expected at least one websocket route" + assert every_ws == guarded diff --git a/tests/test_cmd_arg_monitor_flags.py b/tests/test_cmd_arg_monitor_flags.py new file mode 100644 index 0000000..27ccc9d --- /dev/null +++ b/tests/test_cmd_arg_monitor_flags.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_cmd_arg_monitor_flags.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Tests for the CLI flags added to support unattended monitoring runs. + +Each flag must default to the existing config value, so a manual crawl that does +not pass them behaves exactly as before. +""" + +import pytest + +import config +from cmd_arg.arg import parse_cmd + +BASE_ARGS = ["--platform", "xhs", "--type", "creator", "--creator_id", "abc123"] + + +@pytest.fixture(autouse=True) +def _isolate_config(monkeypatch): + monkeypatch.setattr(config, "ENABLE_CDP_MODE", True) + monkeypatch.setattr(config, "INJECT_ALL_COOKIES", False) + monkeypatch.setattr(config, "SAVE_LOGIN_STATE", True) + monkeypatch.setattr(config, "COOKIES", "") + monkeypatch.setattr(config, "SAVE_DATA_PATH", "") + monkeypatch.setattr(config, "CRAWLER_MAX_SLEEP_SEC", 2) + yield + + +class TestEnableCdpMode: + """CDP attaches to the user's desktop Chrome, which cannot work on a server.""" + + @pytest.mark.asyncio + async def test_false_disables_cdp(self): + await parse_cmd([*BASE_ARGS, "--enable_cdp_mode", "false"]) + assert config.ENABLE_CDP_MODE is False + + @pytest.mark.asyncio + async def test_defaults_to_config_value(self): + await parse_cmd(BASE_ARGS) + assert config.ENABLE_CDP_MODE is True + + +class TestCookieFlags: + @pytest.mark.asyncio + async def test_inject_all_cookies_enables_switch(self): + await parse_cmd([*BASE_ARGS, "--inject_all_cookies", "true"]) + assert config.INJECT_ALL_COOKIES is True + + @pytest.mark.asyncio + async def test_inject_all_cookies_defaults_off(self): + await parse_cmd(BASE_ARGS) + assert config.INJECT_ALL_COOKIES is False + + @pytest.mark.asyncio + async def test_cookies_file_is_read_into_config(self, tmp_path): + cookie_file = tmp_path / "cookies.txt" + cookie_file.write_text("web_session=abc; a1=def", encoding="utf-8") + + await parse_cmd([*BASE_ARGS, "--cookies_file", str(cookie_file)]) + + assert config.COOKIES == "web_session=abc; a1=def" + + @pytest.mark.asyncio + async def test_cookies_file_wins_over_inline_cookies(self, tmp_path): + cookie_file = tmp_path / "cookies.txt" + cookie_file.write_text("web_session=fromfile", encoding="utf-8") + + await parse_cmd( + [*BASE_ARGS, "--cookies", "web_session=inline", "--cookies_file", str(cookie_file)] + ) + + assert config.COOKIES == "web_session=fromfile" + + @pytest.mark.asyncio + async def test_missing_cookies_file_is_rejected(self, tmp_path): + missing = tmp_path / "nope.txt" + + with pytest.raises(Exception) as excinfo: + await parse_cmd([*BASE_ARGS, "--cookies_file", str(missing)]) + + # A silently-ignored unreadable cookie file would produce a crawl that + # returns nothing, which is exactly the failure mode this flag exists + # to avoid. + assert "cookies_file" in str(excinfo.value) + + +class TestSaveDataPath: + @pytest.mark.asyncio + async def test_save_data_path_is_applied(self): + await parse_cmd([*BASE_ARGS, "--save_data_path", "data/monitor_runs/1/2"]) + assert config.SAVE_DATA_PATH == "data/monitor_runs/1/2" + + +class TestSaveLoginState: + @pytest.mark.asyncio + async def test_save_login_state_can_be_disabled(self): + await parse_cmd([*BASE_ARGS, "--save_login_state", "false"]) + assert config.SAVE_LOGIN_STATE is False + + +class TestCrawlSleepSec: + """Exposed on the Settings page; previously had no CLI flag at all.""" + + @pytest.mark.asyncio + async def test_value_is_applied(self): + await parse_cmd([*BASE_ARGS, "--crawler_max_sleep_sec", "9"]) + assert config.CRAWLER_MAX_SLEEP_SEC == 9 + + @pytest.mark.asyncio + async def test_defaults_to_config_value(self, monkeypatch): + monkeypatch.setattr(config, "CRAWLER_MAX_SLEEP_SEC", 4) + await parse_cmd(BASE_ARGS) + assert config.CRAWLER_MAX_SLEEP_SEC == 4 diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py new file mode 100644 index 0000000..b335235 --- /dev/null +++ b/tests/test_interpreter.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_interpreter.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Tests for the subprocess interpreter resolver.""" + +import sys +from pathlib import Path + +from api.services.interpreter import ( + describe_interpreter, + resolve_python_cmd, + venv_python_path, +) + + +def _make_venv(root: Path) -> Path: + """Create a fake venv layout and return the expected python path.""" + exe = venv_python_path(root) + exe.parent.mkdir(parents=True, exist_ok=True) + exe.write_text("", encoding="utf-8") + return exe + + +def test_prefers_uv_when_available(monkeypatch, tmp_path): + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + _make_venv(tmp_path) + + # uv wins even when a venv exists, matching the upstream documented workflow. + assert resolve_python_cmd(tmp_path) == ["uv", "run", "python"] + + +def test_falls_back_to_project_venv(monkeypatch, tmp_path): + monkeypatch.setattr("shutil.which", lambda name: None) + exe = _make_venv(tmp_path) + + assert resolve_python_cmd(tmp_path) == [str(exe)] + + +def test_falls_back_to_current_interpreter(monkeypatch, tmp_path): + monkeypatch.setattr("shutil.which", lambda name: None) + + # No uv, no venv anywhere under the given root. + assert resolve_python_cmd(tmp_path) == [sys.executable] + + +def test_describe_is_human_readable(monkeypatch, tmp_path): + monkeypatch.setattr("shutil.which", lambda name: None) + _make_venv(tmp_path) + assert "virtualenv" in describe_interpreter(tmp_path) + + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + assert describe_interpreter(tmp_path) == "uv run python" diff --git a/tests/test_monitor_api.py b/tests/test_monitor_api.py new file mode 100644 index 0000000..0dee8f3 --- /dev/null +++ b/tests/test_monitor_api.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_api.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""API-level tests for the monitoring endpoints. + +Run against an ASGI transport with a temporary database, so no server, network +or login is required. Lifespan is deliberately not exercised: it would start the +scheduler, and these tests only cover routing, validation and persistence. +""" + +import httpx +import pytest +import pytest_asyncio + +from api.main import app +from api.monitor import db as monitor_db +from api.monitor.service import TargetParseError, parse_target_input + +CREATOR_URL = ( + "https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753" + "?xsec_token=ABYVg1evluJZZzpMX-VWzchxQ1qSNVW3r-jOEnKqMcgZw=&xsec_source=pc_search" +) +NOTE_URL = "https://www.xiaohongshu.com/explore/6aa3d827000000002802c5c8?xsec_token=TOKEN&xsec_source=pc_search" + + +@pytest_asyncio.fixture +async def client(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + await monitor_db.dispose_engine() + + +class TestParseTargetInput: + def test_full_url_splits_id_from_token(self): + """The id is the stable key; the token is a refreshable credential.""" + parsed = parse_target_input(CREATOR_URL, "creator") + assert parsed["external_id"] == "5f58bd990000000001003753" + assert parsed["xsec_token"].startswith("ABYVg1evluJZZzpMX") + assert parsed["xsec_source"] == "pc_search" + + def test_bare_id_is_accepted(self): + parsed = parse_target_input("5f58bd990000000001003753", "creator") + assert parsed["external_id"] == "5f58bd990000000001003753" + assert parsed["xsec_token"] == "" + + def test_note_url_without_token_still_parses(self): + parsed = parse_target_input( + "https://www.xiaohongshu.com/explore/6aa3d827000000002802c5c8", "note" + ) + assert parsed["external_id"] == "6aa3d827000000002802c5c8" + assert parsed["xsec_token"] == "" + + def test_creator_url_rejected_in_note_mode(self): + with pytest.raises(TargetParseError): + parse_target_input(CREATOR_URL, "note") + + def test_garbage_is_rejected(self): + with pytest.raises(TargetParseError): + parse_target_input("not a url at all !!", "creator") + + +class TestTaskCrud: + @pytest.mark.asyncio + async def test_create_and_list_task(self, client): + response = await client.post( + "/api/monitor/tasks", + json={ + "name": "网文作者监控", + "mode": "creator", + "interval_minutes": 120, + "targets": [CREATOR_URL, "5f58bd990000000001003754"], + }, + ) + assert response.status_code == 201 + task_id = response.json()["id"] + + listing = await client.get("/api/monitor/tasks") + assert listing.status_code == 200 + tasks = listing.json()["tasks"] + assert len(tasks) == 1 + assert tasks[0]["id"] == task_id + assert tasks[0]["target_count"] == 2 + # next_run_at is persisted so the schedule survives a restart. + assert tasks[0]["next_run_at"] is not None + + @pytest.mark.asyncio + async def test_duplicate_targets_are_deduplicated(self, client): + response = await client.post( + "/api/monitor/tasks", + json={ + "name": "dedup", + "mode": "creator", + "targets": [CREATOR_URL, CREATOR_URL], + }, + ) + assert response.status_code == 201 + + listing = await client.get("/api/monitor/tasks") + assert listing.json()["tasks"][0]["target_count"] == 1 + + @pytest.mark.asyncio + async def test_invalid_target_returns_400(self, client): + response = await client.post( + "/api/monitor/tasks", + json={"name": "bad", "mode": "creator", "targets": ["!!! nonsense !!!"]}, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_interval_floor_is_enforced(self, client): + """A tight poll loop is the pattern that triggers platform rate limits.""" + response = await client.post( + "/api/monitor/tasks", + json={"name": "too fast", "mode": "creator", "interval_minutes": 1, "targets": [CREATOR_URL]}, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_update_and_delete(self, client): + created = await client.post( + "/api/monitor/tasks", + json={"name": "t", "mode": "note", "targets": [NOTE_URL]}, + ) + task_id = created.json()["id"] + + patched = await client.patch(f"/api/monitor/tasks/{task_id}", json={"enabled": False}) + assert patched.status_code == 200 + listing = await client.get("/api/monitor/tasks") + assert listing.json()["tasks"][0]["enabled"] is False + + deleted = await client.delete(f"/api/monitor/tasks/{task_id}") + assert deleted.status_code == 200 + assert (await client.get("/api/monitor/tasks")).json()["tasks"] == [] + + @pytest.mark.asyncio + async def test_run_now_on_missing_task_is_404(self, client): + response = await client.post("/api/monitor/tasks/9999/run") + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_run_history_starts_empty(self, client): + created = await client.post( + "/api/monitor/tasks", + json={"name": "t", "mode": "creator", "targets": [CREATOR_URL]}, + ) + task_id = created.json()["id"] + runs = await client.get(f"/api/monitor/tasks/{task_id}/runs") + assert runs.status_code == 200 + assert runs.json()["runs"] == [] + + +class TestCookieEndpoints: + @pytest.mark.asyncio + async def test_cookie_value_is_never_returned(self, client): + """The GET must expose health only, never the credential.""" + secret = "web_session=SUPERSECRETVALUE; a1=abc123" + saved = await client.post("/api/monitor/cookie", json={"cookie": secret}) + assert saved.status_code == 200 + + status_response = await client.get("/api/monitor/cookie") + assert status_response.status_code == 200 + body = status_response.json() + + assert body["present"] is True + assert body["length"] == len(secret) + assert "SUPERSECRETVALUE" not in status_response.text + + @pytest.mark.asyncio + async def test_cookie_initially_absent_and_clearable(self, client): + assert (await client.get("/api/monitor/cookie")).json()["present"] is False + + await client.post("/api/monitor/cookie", json={"cookie": "web_session=x"}) + assert (await client.get("/api/monitor/cookie")).json()["present"] is True + + await client.delete("/api/monitor/cookie") + assert (await client.get("/api/monitor/cookie")).json()["present"] is False + + +class TestDashboardQueries: + @pytest.mark.asyncio + async def test_empty_dashboard_shapes(self, client): + assert (await client.get("/api/monitor/notes")).json()["notes"] == [] + assert (await client.get("/api/monitor/comments")).json()["comments"] == [] + assert (await client.get("/api/monitor/events")).json()["events"] == [] + + overview = (await client.get("/api/monitor/overview")).json() + assert overview["tasks"] == 0 + assert overview["notes"] == 0 diff --git a/tests/test_monitor_comments.py b/tests/test_monitor_comments.py new file mode 100644 index 0000000..6ca7152 --- /dev/null +++ b/tests/test_monitor_comments.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_comments.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Comment note-association, grouping, and the export endpoint.""" + +import csv +import io + +import httpx +import pytest +import pytest_asyncio + +from api.main import app +from api.monitor import db as monitor_db +from api.monitor.models import ( + MODE_CREATOR, + MonitorComment, + MonitorNote, + MonitorTask, +) + +TASK_NAME = "评论归属测试" + + +async def _seed(): + """Two works; three comments on the first, one on the second.""" + async with monitor_db.get_session() as session: + task = MonitorTask( + name=TASK_NAME, platform="xhs", mode=MODE_CREATOR, enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=True, + max_comments_count=50, run_timeout_seconds=3600, + notify_enabled=False, created_at=0, updated_at=0, + ) + session.add(task) + await session.flush() + + for note_id, title in (("note-a", "作品甲"), ("note-b", "作品乙")): + session.add( + MonitorNote( + task_id=task.id, note_id=note_id, title=title, + note_url=f"https://www.xiaohongshu.com/explore/{note_id}", + cover=f"https://img/{note_id}.jpg", creator_hash="h", + source_kind="video", published_at=None, + first_seen_run_id=1, first_seen_at=1_700_000_000_000, + last_seen_run_id=1, last_seen_at=1_700_000_000_000, + ) + ) + + # note-a has three comments, note-b has one. + plan = [ + ("c1", "note-a", 1_700_000_001_000), + ("c2", "note-a", 1_700_000_002_000), + ("c3", "note-a", 1_700_000_003_000), + ("c4", "note-b", 1_700_000_004_000), + ] + for comment_id, note_id, seen_at in plan: + session.add( + MonitorComment( + task_id=task.id, note_id=note_id, comment_id=comment_id, + content=f"内容-{comment_id}", nickname="u***r", creator_hash="h", + create_time=seen_at, like_count=1, sub_comment_count=0, + parent_comment_id="", first_seen_run_id=1, first_seen_at=seen_at, + ) + ) + return task.id + + +@pytest_asyncio.fixture +async def client(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + await _seed() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + await monitor_db.dispose_engine() + + +class TestCommentsCarryTheirNote: + @pytest.mark.asyncio + async def test_each_comment_names_its_work(self, client): + """A bare note_id is unreadable -- the title is the whole point.""" + response = await client.get("/api/monitor/comments") + assert response.status_code == 200 + + comments = response.json()["comments"] + assert len(comments) == 4 + + by_id = {c["comment_id"]: c for c in comments} + assert by_id["c1"]["note_title"] == "作品甲" + assert by_id["c1"]["note_url"].endswith("note-a") + assert by_id["c1"]["note_cover"].endswith("note-a.jpg") + assert by_id["c4"]["note_title"] == "作品乙" + + @pytest.mark.asyncio + async def test_note_id_filters_the_stream(self, client): + response = await client.get("/api/monitor/comments", params={"note_id": "note-a"}) + comments = response.json()["comments"] + assert {c["comment_id"] for c in comments} == {"c1", "c2", "c3"} + + +class TestGroupByNote: + @pytest.mark.asyncio + async def test_groups_bucket_by_work(self, client): + response = await client.get("/api/monitor/comments", params={"group_by": "note"}) + body = response.json() + + assert "groups" in body + assert body["total"] == 4 + + groups = {g["note_id"]: g for g in body["groups"]} + assert set(groups) == {"note-a", "note-b"} + assert len(groups["note-a"]["comments"]) == 3 + assert len(groups["note-b"]["comments"]) == 1 + assert groups["note-a"]["note_title"] == "作品甲" + + @pytest.mark.asyncio + async def test_newest_group_comes_first(self, client): + """The UI expands the first group by default, so it must be the newest.""" + response = await client.get("/api/monitor/comments", params={"group_by": "note"}) + groups = response.json()["groups"] + # note-b's only comment is the most recent overall. + assert groups[0]["note_id"] == "note-b" + + @pytest.mark.asyncio + async def test_flat_shape_is_unchanged_without_the_flag(self, client): + body = (await client.get("/api/monitor/comments")).json() + assert "comments" in body and "groups" not in body + + +class TestCommentNoteFilterOptions: + @pytest.mark.asyncio + async def test_options_carry_counts_and_titles(self, client): + response = await client.get("/api/monitor/comment-notes") + assert response.status_code == 200 + + notes = {n["note_id"]: n for n in response.json()["notes"]} + assert notes["note-a"]["comment_count"] == 3 + assert notes["note-b"]["comment_count"] == 1 + assert notes["note-a"]["note_title"] == "作品甲" + + @pytest.mark.asyncio + async def test_scoped_to_a_task(self, client): + tasks = (await client.get("/api/monitor/tasks")).json()["tasks"] + task_id = tasks[0]["id"] + + scoped = await client.get("/api/monitor/comment-notes", params={"task_id": task_id}) + assert len(scoped.json()["notes"]) == 2 + + # A task with no comments yields an empty list, not an error. + other = await client.get("/api/monitor/comment-notes", params={"task_id": 9999}) + assert other.json()["notes"] == [] + + +class TestExport: + @pytest.mark.asyncio + async def test_csv_has_a_bom_so_excel_does_not_mangle_chinese(self, client): + response = await client.get("/api/monitor/export", params={"kind": "comments"}) + assert response.status_code == 200 + assert response.content.startswith(b"\xef\xbb\xbf") + assert "attachment" in response.headers["content-disposition"] + + text = response.content.decode("utf-8-sig") + rows = list(csv.DictReader(io.StringIO(text))) + assert len(rows) == 4 + assert rows[0]["所属作品"] in ("作品甲", "作品乙") + + @pytest.mark.asyncio + async def test_notes_export(self, client): + response = await client.get( + "/api/monitor/export", params={"kind": "notes", "format": "csv"} + ) + rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig")))) + assert {r["作品ID"] for r in rows} == {"note-a", "note-b"} + + @pytest.mark.asyncio + async def test_xlsx_is_a_readable_workbook(self, client): + from openpyxl import load_workbook + + response = await client.get( + "/api/monitor/export", params={"kind": "comments", "format": "xlsx"} + ) + assert response.status_code == 200 + + workbook = load_workbook(io.BytesIO(response.content)) + sheet = workbook.active + assert sheet.max_row == 5 # header + four comments + assert sheet.cell(row=1, column=1).value == "所属作品" + + @pytest.mark.asyncio + async def test_report_export(self, client): + response = await client.get( + "/api/monitor/export", + params={"kind": "report", "days": 3}, + ) + rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig")))) + assert len(rows) == 3 + assert "日期" in rows[0] + + @pytest.mark.asyncio + async def test_unknown_kind_and_format_are_rejected(self, client): + assert ( + await client.get("/api/monitor/export", params={"kind": "nope"}) + ).status_code == 400 + assert ( + await client.get("/api/monitor/export", params={"kind": "notes", "format": "pdf"}) + ).status_code == 400 + + @pytest.mark.asyncio + async def test_empty_selection_is_a_404_not_an_empty_file(self, client): + """An empty download looks like a bug; say so instead.""" + response = await client.get( + "/api/monitor/export", params={"kind": "comments", "note_id": "no-such-note"} + ) + assert response.status_code == 404 diff --git a/tests/test_monitor_ingest.py b/tests/test_monitor_ingest.py new file mode 100644 index 0000000..d090025 --- /dev/null +++ b/tests/test_monitor_ingest.py @@ -0,0 +1,527 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_ingest.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Offline tests for the monitoring ingest/diff layer. + +These run without network, browser or login and cover the correctness caveats +that matter most: baseline suppression, count parsing, NULL-vs-zero, the +posted/seen comment split, idempotency, and the silent-cookie-failure signal. +""" + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from tools.time_util import get_current_timestamp + +from api.monitor.ingest import describe_exit_code, ingest_run, parse_count +from api.monitor.models import ( + EVENT_AUTH_FAILURE, + EVENT_METRIC_DELTA, + EVENT_NEW_COMMENT_POSTED, + EVENT_NEW_COMMENT_SEEN, + EVENT_NEW_NOTE, + EVENT_NO_DATA, + EVENT_RUN_FAILED, + MODE_CREATOR, + MonitorBase, + MonitorEvent, + MonitorNote, + MonitorNoteMetric, + MonitorRun, + MonitorTask, + RUN_FAILED, + RUN_PARTIAL, + RUN_SUCCESS, +) + + +@pytest_asyncio.fixture +async def db(): + """An isolated in-memory monitoring database.""" + engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool) + async with engine.begin() as conn: + await conn.run_sync(MonitorBase.metadata.create_all) + + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as db_session: + yield db_session + + await engine.dispose() + + +async def _make_task(db: AsyncSession, **overrides) -> MonitorTask: + defaults = dict( + name="test task", + platform="xhs", + mode=MODE_CREATOR, + enabled=True, + interval_minutes=60, + max_notes_count=20, + enable_comments=True, + max_comments_count=50, + run_timeout_seconds=3600, + created_at=0, + updated_at=0, + ) + defaults.update(overrides) + task = MonitorTask(**defaults) + db.add(task) + await db.flush() + return task + + +async def _make_run( + db: AsyncSession, + task: MonitorTask, + started_at: int, + exit_code: Optional[int] = 0, +) -> MonitorRun: + run = MonitorRun( + task_id=task.id, + trigger="manual", + status=RUN_SUCCESS, + phase=task.mode, + save_data_path="", + queued_at=started_at, + not_before=0, + started_at=started_at, + exit_code=exit_code, + ) + db.add(run) + await db.flush() + return run + + +def _write_run_dir( + root: Path, + notes: List[Dict[str, Any]], + comments: Optional[List[Dict[str, Any]]] = None, +) -> Path: + """Write a run's jsonl output in the crawler's own layout.""" + jsonl_dir = root / "xhs" / "jsonl" + jsonl_dir.mkdir(parents=True, exist_ok=True) + + contents = jsonl_dir / "creator_contents_2026-01-01.jsonl" + contents.write_text( + "\n".join(json.dumps(n, ensure_ascii=False) for n in notes), + encoding="utf-8", + ) + if comments is not None: + comment_file = jsonl_dir / "creator_comments_2026-01-01.jsonl" + comment_file.write_text( + "\n".join(json.dumps(c, ensure_ascii=False) for c in comments), + encoding="utf-8", + ) + return root + + +def _note(note_id: str, liked: Any = "10", **extra) -> Dict[str, Any]: + record = { + "note_id": note_id, + "title": f"title-{note_id}", + "note_url": f"https://www.xiaohongshu.com/explore/{note_id}", + "image_list": "https://img/cover.jpg", + "creator_hash": "hash", + "time": 1700000000000, + "liked_count": liked, + "comment_count": "1", + "collected_count": "1", + "share_count": "1", + } + record.update(extra) + return record + + +def _comment(comment_id: str, note_id: str, create_time: int, **extra) -> Dict[str, Any]: + record = { + "comment_id": comment_id, + "note_id": note_id, + "content": f"content-{comment_id}", + "nickname": "u***r", + "creator_hash": "hash", + "create_time": create_time, + "like_count": "0", + "sub_comment_count": 0, + "parent_comment_id": "", + } + record.update(extra) + return record + + +async def _events(db: AsyncSession, event_type: Optional[str] = None) -> List[MonitorEvent]: + stmt = select(MonitorEvent) + if event_type: + stmt = stmt.where(MonitorEvent.type == event_type) + return list((await db.scalars(stmt)).all()) + + +# -------------------------------------------------------------------------- +# parse_count +# -------------------------------------------------------------------------- + +class TestParseCount: + @pytest.mark.parametrize( + "raw,expected", + [ + ("1234", 1234), + ("1.2万", 12000), + ("1.2w", 12000), + ("3亿", 300000000), + ("1,234", 1234), + (42, 42), + ], + ) + def test_parses_platform_formats(self, raw, expected): + assert parse_count(raw) == expected + + @pytest.mark.parametrize("raw", ["", None, "暂无", "-", "abc", True]) + def test_unparseable_values_return_none(self, raw): + assert parse_count(raw) is None + + +# -------------------------------------------------------------------------- +# Exit codes +# -------------------------------------------------------------------------- + +class TestExitCodeStorage: + """Guards a bug that only showed up when the data moved to MySQL. + + Windows reports process failures as unsigned 32-bit NTSTATUS values + (0xC0000142 = 3221225794). That overflows MySQL's signed INT, while SQLite's + dynamic typing accepted it happily -- so the column silently worked until a + real migration hit it with real data. + """ + + def test_column_is_bigint_not_int(self): + from sqlalchemy import BigInteger + + from api.monitor.models import MonitorRun + + column_type = MonitorRun.__table__.c.exit_code.type + assert isinstance(column_type, BigInteger), ( + f"exit_code must be BigInteger to hold unsigned 32-bit codes, got {column_type!r}" + ) + + @pytest.mark.asyncio + async def test_an_ntstatus_value_round_trips(self, db): + task = await _make_task(db) + run = await _make_run(db, task, started_at=1000, exit_code=3221225794) + await db.commit() + + stored = await db.scalar( + select(MonitorRun.exit_code).where(MonitorRun.id == run.id) + ) + assert stored == 3221225794 + + +class TestDescribeExitCode: + def test_windows_status_code_is_decoded(self): + """3221225794 is 0xC0000142, which is meaningless without decoding.""" + message = describe_exit_code(3221225794) + assert "0xC0000142" in message + assert "DLL_INIT_FAILED" in message + + def test_negative_signed_form_is_also_decoded(self): + # Python may hand back the signed form depending on how it was launched. + assert "0xC0000142" in describe_exit_code(-1073741502) + + def test_unknown_code_degrades_to_the_raw_number(self): + assert describe_exit_code(1) == "Crawler exited with code 1" + + +# -------------------------------------------------------------------------- +# Notes +# -------------------------------------------------------------------------- + +class TestNoteIngest: + @pytest.mark.asyncio + async def test_baseline_run_emits_no_new_note_events(self, db, tmp_path): + task = await _make_task(db) + run = await _make_run(db, task, started_at=1000) + _write_run_dir(tmp_path, [_note("n1"), _note("n2")], comments=[]) + + result = await ingest_run(db, run, task, tmp_path) + + assert result.status == RUN_SUCCESS + assert result.is_baseline is True + assert result.new_notes == 2 + # Everything is "new" on the first run; emitting that would be pure noise. + assert await _events(db, EVENT_NEW_NOTE) == [] + assert len(list((await db.scalars(select(MonitorNote))).all())) == 2 + + @pytest.mark.asyncio + async def test_an_empty_run_does_not_establish_a_baseline(self, db, tmp_path): + """A run that fetched nothing observed nothing, so it is not a baseline. + + Otherwise the first crawl that actually works reports every work as + newly discovered. + """ + task = await _make_task(db) + + empty_run = await _make_run(db, task, started_at=1000) + (tmp_path / "empty").mkdir(parents=True, exist_ok=True) + await ingest_run(db, empty_run, task, tmp_path / "empty") + + real_run = await _make_run(db, task, started_at=2000) + result = await ingest_run( + db, real_run, task, _write_run_dir(tmp_path / "ok", [_note("n1")], comments=[]) + ) + + assert result.is_baseline is True + assert await _events(db, EVENT_NEW_NOTE) == [] + + @pytest.mark.asyncio + async def test_second_run_reports_only_the_added_note(self, db, tmp_path): + task = await _make_task(db) + + first_dir = _write_run_dir(tmp_path / "run1", [_note("n1")], comments=[]) + run1 = await _make_run(db, task, started_at=1000) + await ingest_run(db, run1, task, first_dir) + + second_dir = _write_run_dir(tmp_path / "run2", [_note("n1"), _note("n2")], comments=[]) + run2 = await _make_run(db, task, started_at=2000) + result = await ingest_run(db, run2, task, second_dir) + + assert result.is_baseline is False + assert result.new_notes == 1 + + events = await _events(db, EVENT_NEW_NOTE) + assert len(events) == 1 + assert events[0].target_id == "n2" + assert events[0].run_id == run2.id + + +class TestMetricSnapshots: + @pytest.mark.asyncio + async def test_delta_event_emitted_when_like_count_changes(self, db, tmp_path): + task = await _make_task(db) + + run1 = await _make_run(db, task, started_at=1000) + await ingest_run(db, run1, task, _write_run_dir(tmp_path / "r1", [_note("n1", "100")], comments=[])) + + run2 = await _make_run(db, task, started_at=2000) + await ingest_run(db, run2, task, _write_run_dir(tmp_path / "r2", [_note("n1", "150")], comments=[])) + + events = await _events(db, EVENT_METRIC_DELTA) + assert len(events) == 1 + + payload = json.loads(events[0].payload_json) + assert payload["deltas"]["liked_count"] == {"from": 100, "to": 150, "delta": 50} + + @pytest.mark.asyncio + async def test_no_delta_when_nothing_changed(self, db, tmp_path): + task = await _make_task(db) + run1 = await _make_run(db, task, started_at=1000) + await ingest_run(db, run1, task, _write_run_dir(tmp_path / "r1", [_note("n1", "100")], comments=[])) + run2 = await _make_run(db, task, started_at=2000) + await ingest_run(db, run2, task, _write_run_dir(tmp_path / "r2", [_note("n1", "100")], comments=[])) + + assert await _events(db, EVENT_METRIC_DELTA) == [] + + @pytest.mark.asyncio + async def test_unparseable_count_is_null_not_zero(self, db, tmp_path): + task = await _make_task(db) + run = await _make_run(db, task, started_at=1000) + await ingest_run(db, run, task, _write_run_dir(tmp_path, [_note("n1", "暂无")], comments=[])) + + metric = await db.scalar(select(MonitorNoteMetric).where(MonitorNoteMetric.note_id == "n1")) + # Zero would forge a large negative delta on the next comparison. + assert metric.liked_count is None + assert metric.raw_liked_count == "暂无" + + @pytest.mark.asyncio + async def test_no_delta_when_previous_value_was_unparseable(self, db, tmp_path): + task = await _make_task(db) + run1 = await _make_run(db, task, started_at=1000) + await ingest_run(db, run1, task, _write_run_dir(tmp_path / "r1", [_note("n1", "暂无")], comments=[])) + run2 = await _make_run(db, task, started_at=2000) + await ingest_run(db, run2, task, _write_run_dir(tmp_path / "r2", [_note("n1", "50")], comments=[])) + + assert await _events(db, EVENT_METRIC_DELTA) == [] + + @pytest.mark.asyncio + async def test_metric_snapshot_survives_across_runs(self, db, tmp_path): + """The crawler's own DB store overwrites metrics; ours must not.""" + task = await _make_task(db) + for index, liked in enumerate(["100", "150", "300"]): + run = await _make_run(db, task, started_at=1000 * (index + 1)) + await ingest_run( + db, run, task, _write_run_dir(tmp_path / f"r{index}", [_note("n1", liked)], comments=[]) + ) + + snapshots = list( + ( + await db.scalars( + select(MonitorNoteMetric) + .where(MonitorNoteMetric.note_id == "n1") + .order_by(MonitorNoteMetric.run_id) + ) + ).all() + ) + assert [s.liked_count for s in snapshots] == [100, 150, 300] + + +# -------------------------------------------------------------------------- +# Comments +# -------------------------------------------------------------------------- + +class TestCommentIngest: + @pytest.mark.asyncio + async def test_posted_vs_seen_split_by_create_time(self, db, tmp_path): + task = await _make_task(db) + + # Baseline establishes the seen-set; no events on the first run. + run1 = await _make_run(db, task, started_at=1000) + await ingest_run( + db, run1, task, + _write_run_dir(tmp_path / "r1", [_note("n1")], comments=[_comment("c1", "n1", create_time=500)]), + ) + assert await _events(db, EVENT_NEW_COMMENT_POSTED) == [] + + # c2 was published after run1 started -> genuinely new. + # c3 is old but only just surfaced in the top-N window -> seen, not posted. + run2 = await _make_run(db, task, started_at=2000) + await ingest_run( + db, run2, task, + _write_run_dir( + tmp_path / "r2", + [_note("n1")], + comments=[ + _comment("c1", "n1", create_time=500), + _comment("c2", "n1", create_time=2500), + _comment("c3", "n1", create_time=100), + ], + ), + ) + + posted = await _events(db, EVENT_NEW_COMMENT_POSTED) + seen = await _events(db, EVENT_NEW_COMMENT_SEEN) + assert len(posted) == 1 + assert json.loads(posted[0].payload_json)["comment_id"] == "c2" + assert len(seen) == 1 + assert json.loads(seen[0].payload_json)["comment_id"] == "c3" + + @pytest.mark.asyncio + async def test_comments_not_ingested_when_disabled(self, db, tmp_path): + task = await _make_task(db, enable_comments=False) + run = await _make_run(db, task, started_at=1000) + result = await ingest_run( + db, run, task, + _write_run_dir(tmp_path, [_note("n1")], comments=[_comment("c1", "n1", 500)]), + ) + assert result.new_comments == 0 + + +# -------------------------------------------------------------------------- +# Failure handling +# -------------------------------------------------------------------------- + +class TestFailureHandling: + @pytest.mark.asyncio + async def test_nonzero_exit_is_a_failure(self, db, tmp_path): + task = await _make_task(db) + run = await _make_run(db, task, started_at=1000, exit_code=1) + _write_run_dir(tmp_path, [_note("n1")], comments=[]) + + result = await ingest_run(db, run, task, tmp_path) + + assert result.status == RUN_FAILED + assert len(await _events(db, EVENT_RUN_FAILED)) == 1 + # A crashed run must not touch the seen-set. + assert await db.scalar(select(MonitorNote.id)) is None + + @pytest.mark.asyncio + async def test_zero_notes_with_exit_zero_is_a_suspected_auth_failure(self, db, tmp_path): + """The silent-cookie-failure signature: exit 0 but nothing fetched. + + A real bad-cookie run writes no output file at all, which is why the + exit code has to be checked before the files are. + """ + task = await _make_task(db) + run = await _make_run(db, task, started_at=1000, exit_code=0) + tmp_path.mkdir(parents=True, exist_ok=True) + + result = await ingest_run(db, run, task, tmp_path) + + assert result.status == RUN_PARTIAL + assert len(await _events(db, EVENT_AUTH_FAILURE)) == 1 + assert await _events(db, EVENT_RUN_FAILED) == [] + + @pytest.mark.asyncio + async def test_no_data_is_not_blamed_on_the_cookie_when_a_sibling_succeeded( + self, db, tmp_path + ): + """A task that just worked proves the login is fine; do not cry wolf.""" + healthy = await _make_task(db, name="healthy") + healthy_run = await _make_run(db, healthy, started_at=get_current_timestamp()) + await ingest_run( + db, healthy_run, healthy, + _write_run_dir(tmp_path / "ok", [_note("n1")], comments=[]), + ) + + task = await _make_task(db, name="suspect") + run = await _make_run(db, task, started_at=get_current_timestamp()) + (tmp_path / "empty").mkdir(parents=True, exist_ok=True) + result = await ingest_run(db, run, task, tmp_path / "empty") + + assert result.status == RUN_PARTIAL + assert await _events(db, EVENT_NO_DATA) != [] + assert await _events(db, EVENT_AUTH_FAILURE) == [] + + @pytest.mark.asyncio + async def test_empty_contents_file_is_also_an_auth_failure(self, db, tmp_path): + task = await _make_task(db) + run = await _make_run(db, task, started_at=1000, exit_code=0) + _write_run_dir(tmp_path, [], comments=[]) + + result = await ingest_run(db, run, task, tmp_path) + + assert result.status == RUN_PARTIAL + assert len(await _events(db, EVENT_AUTH_FAILURE)) == 1 + + +# -------------------------------------------------------------------------- +# Idempotency +# -------------------------------------------------------------------------- + +class TestIdempotency: + @pytest.mark.asyncio + async def test_reingesting_the_same_data_adds_nothing(self, db, tmp_path): + task = await _make_task(db) + run_dir = _write_run_dir( + tmp_path, [_note("n1"), _note("n2")], comments=[_comment("c1", "n1", 500)] + ) + + run1 = await _make_run(db, task, started_at=1000) + await ingest_run(db, run1, task, run_dir) + notes_after_first = len(list((await db.scalars(select(MonitorNote))).all())) + + # A retry of the same crawl content must not duplicate rows or events. + run2 = await _make_run(db, task, started_at=2000) + result = await ingest_run(db, run2, task, run_dir) + + assert result.new_notes == 0 + assert result.new_comments == 0 + assert len(list((await db.scalars(select(MonitorNote))).all())) == notes_after_first diff --git a/tests/test_monitor_notify.py b/tests/test_monitor_notify.py new file mode 100644 index 0000000..9db23fe --- /dev/null +++ b/tests/test_monitor_notify.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_notify.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Tests for the WeCom notification layer. + +The webhook is stubbed, so nothing here touches the network. +""" + +import json + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from api.monitor import notify +from api.monitor.models import ( + EVENT_AUTH_FAILURE, + EVENT_METRIC_DELTA, + EVENT_NEW_NOTE, + EVENT_NEW_COMMENT_POSTED, + MODE_CREATOR, + SETTING_WECOM_WEBHOOK, + MonitorBase, + MonitorEvent, + MonitorRun, + MonitorTask, + RUN_SUCCESS, +) +from api.monitor.settings import set_setting + +WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abc123" + + +@pytest_asyncio.fixture +async def db(): + engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool) + async with engine.begin() as conn: + await conn.run_sync(MonitorBase.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + yield session + await engine.dispose() + + +async def _seed(db: AsyncSession, notify_enabled: bool = True): + task = MonitorTask( + name="竞品监控", platform="xhs", mode=MODE_CREATOR, enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=True, + max_comments_count=50, run_timeout_seconds=3600, + notify_enabled=notify_enabled, created_at=0, updated_at=0, + ) + db.add(task) + await db.flush() + + run = MonitorRun( + task_id=task.id, trigger="scheduled", status=RUN_SUCCESS, phase=MODE_CREATOR, + save_data_path="", queued_at=0, not_before=0, max_comments_count=50, + ) + db.add(run) + await db.flush() + return task, run + + +def _add_event(db, task, run, event_type, title, payload=None, severity="info"): + db.add( + MonitorEvent( + task_id=task.id, run_id=run.id, type=event_type, severity=severity, + target_kind="note", target_id="note-1", title=title, + payload_json=json.dumps(payload or {}, ensure_ascii=False), created_at=0, + ) + ) + + +# -------------------------------------------------------------------------- +# Message building +# -------------------------------------------------------------------------- + +class TestBuildRunMessage: + @pytest.mark.asyncio + async def test_no_notifiable_events_means_no_message(self, db): + task, run = await _seed(db) + # Metric deltas are not something anyone wants pushed. + _add_event(db, task, run, EVENT_METRIC_DELTA, "点赞 10→20") + _add_event(db, task, run, EVENT_NEW_COMMENT_POSTED, "新评论") + await db.flush() + + assert await notify.build_run_message(db, task, run) is None + + @pytest.mark.asyncio + async def test_new_notes_are_listed_with_links(self, db): + task, run = await _seed(db) + _add_event( + db, task, run, EVENT_NEW_NOTE, "新作品:标题A", + payload={"note_id": "abc123", "title": "标题A"}, + ) + await db.flush() + + message = await notify.build_run_message(db, task, run) + + assert "竞品监控" in message + assert "新增作品 **1** 篇" in message + assert "标题A" in message + assert "https://www.xiaohongshu.com/explore/abc123" in message + + @pytest.mark.asyncio + async def test_long_note_lists_are_truncated(self, db): + """A first run can find dozens; a wall of text is worse than a count.""" + task, run = await _seed(db) + for index in range(14): + _add_event( + db, task, run, EVENT_NEW_NOTE, f"新作品:{index}", + payload={"note_id": f"n{index}", "title": f"标题{index}"}, + ) + await db.flush() + + message = await notify.build_run_message(db, task, run) + + assert "新增作品 **14** 篇" in message + assert "标题0" in message + assert "标题13" not in message + assert "等共 14 篇" in message + + @pytest.mark.asyncio + async def test_failure_is_reported_as_a_warning(self, db): + task, run = await _seed(db) + _add_event( + db, task, run, EVENT_AUTH_FAILURE, + "疑似登录态失效:本次未抓到任何作品", severity="error", + ) + await db.flush() + + message = await notify.build_run_message(db, task, run) + + assert "异常" in message + assert "登录态失效" in message + assert notify._COLOR_WARNING in message + + @pytest.mark.asyncio + async def test_baseline_runs_say_so(self, db): + task, run = await _seed(db) + run.is_baseline = True + _add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"}) + await db.flush() + + message = await notify.build_run_message(db, task, run) + + assert "基线" in message + + +# -------------------------------------------------------------------------- +# notify_run gating +# -------------------------------------------------------------------------- + +class TestNotifyRunGating: + @pytest.mark.asyncio + async def test_disabled_task_is_skipped(self, db, monkeypatch): + task, run = await _seed(db, notify_enabled=False) + _add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"}) + await set_setting(db, SETTING_WECOM_WEBHOOK, WEBHOOK) + await db.flush() + + called = [] + monkeypatch.setattr(notify, "send_wecom", lambda *a, **k: called.append(a) or _ok()) + + assert await notify.notify_run(db, task, run) is None + assert called == [] + + @pytest.mark.asyncio + async def test_missing_webhook_is_skipped(self, db, monkeypatch): + task, run = await _seed(db, notify_enabled=True) + _add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"}) + await db.flush() + + called = [] + monkeypatch.setattr(notify, "send_wecom", lambda *a, **k: called.append(a) or _ok()) + + assert await notify.notify_run(db, task, run) is None + assert called == [] + + @pytest.mark.asyncio + async def test_successful_push_records_the_timestamp(self, db, monkeypatch): + task, run = await _seed(db, notify_enabled=True) + _add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"}) + await set_setting(db, SETTING_WECOM_WEBHOOK, WEBHOOK) + await db.flush() + + monkeypatch.setattr(notify, "send_wecom", lambda *a, **k: _ok()) + + message = await notify.notify_run(db, task, run) + + assert message is not None + # Lets the UI answer "why did I not get a push for this run?". + assert task.last_notified_at is not None + + @pytest.mark.asyncio + async def test_push_failure_never_raises(self, db, monkeypatch): + """A broken webhook must not take down the crawl that just succeeded.""" + task, run = await _seed(db, notify_enabled=True) + _add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"}) + await set_setting(db, SETTING_WECOM_WEBHOOK, WEBHOOK) + await db.flush() + + async def _boom(*args, **kwargs): + raise RuntimeError("network exploded") + + monkeypatch.setattr(notify, "send_wecom", _boom) + + assert await notify.notify_run(db, task, run) is None + + +async def _ok(): + return True, "发送成功" + + +# -------------------------------------------------------------------------- +# send_wecom +# -------------------------------------------------------------------------- + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +class _FakeClient: + """Captures the request and replays a canned WeCom reply.""" + + last_payload = None + + def __init__(self, reply=None, error=None): + self._reply = reply if reply is not None else {"errcode": 0, "errmsg": "ok"} + self._error = error + + def __call__(self, *args, **kwargs): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, json=None): + if self._error: + raise self._error + type(self).last_payload = json + return _FakeResponse(self._reply) + + +class TestSendWecom: + @pytest.mark.asyncio + async def test_missing_url_is_reported(self): + ok, detail = await notify.send_wecom("", "hi") + assert ok is False + assert "未配置" in detail + + @pytest.mark.asyncio + async def test_success(self, monkeypatch): + monkeypatch.setattr(notify.httpx, "AsyncClient", _FakeClient()) + + ok, detail = await notify.send_wecom(WEBHOOK, "**标题**\n> 内容") + + assert ok is True + assert detail == "发送成功" + # WeCom expects a markdown message envelope. + assert _FakeClient.last_payload["msgtype"] == "markdown" + assert _FakeClient.last_payload["markdown"]["content"] == "**标题**\n> 内容" + + @pytest.mark.asyncio + async def test_nonzero_errcode_is_a_failure(self, monkeypatch): + """WeCom answers HTTP 200 even when it rejects the message.""" + monkeypatch.setattr( + notify.httpx, "AsyncClient", + _FakeClient(reply={"errcode": 93000, "errmsg": "invalid webhook url"}), + ) + + ok, detail = await notify.send_wecom(WEBHOOK, "hi") + + assert ok is False + assert "93000" in detail + + @pytest.mark.asyncio + async def test_network_error_is_returned_not_raised(self, monkeypatch): + import httpx + + monkeypatch.setattr( + notify.httpx, "AsyncClient", + _FakeClient(error=httpx.ConnectError("boom")), + ) + + ok, detail = await notify.send_wecom(WEBHOOK, "hi") + + assert ok is False + assert "请求失败" in detail diff --git a/tests/test_monitor_report.py b/tests/test_monitor_report.py new file mode 100644 index 0000000..f71726b --- /dev/null +++ b/tests/test_monitor_report.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_report.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Tests for the cross-task report aggregation. + +The interaction delta is the part that is easy to get subtly wrong, so it is +covered directly against the pure aggregation function. +""" + +from datetime import date, datetime + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from api.monitor.models import ( + MODE_CREATOR, + MonitorBase, + MonitorComment, + MonitorNote, + MonitorNoteMetric, + MonitorTask, +) +from api.monitor.report import build_report, compute_daily_rows, day_bounds, iter_days + + +def _ms(year: int, month: int, day: int, hour: int = 12) -> int: + return int(datetime(year, month, day, hour).timestamp() * 1000) + + +def _metrics(liked=0, comment=0, collected=0, share=0): + """All four metrics default to parsed values; pass None to simulate a + platform value we could not parse.""" + return { + "liked_count": liked, + "comment_count": comment, + "collected_count": collected, + "share_count": share, + } + + +class TestDayHelpers: + def test_day_bounds_cover_the_whole_local_day(self): + start, end = day_bounds(date(2026, 1, 10)) + assert start < _ms(2026, 1, 10, 0) or start == _ms(2026, 1, 10, 0) + assert end > _ms(2026, 1, 10, 23) + + def test_iter_days_is_inclusive(self): + days = iter_days(date(2026, 1, 10), date(2026, 1, 12)) + assert days == [date(2026, 1, 10), date(2026, 1, 11), date(2026, 1, 12)] + + +class TestInteractionDelta: + def test_note_first_seen_counts_all_of_its_value(self): + """A brand-new note has no earlier baseline, so it starts from zero.""" + day = date(2026, 1, 10) + series = {"n1": [(_ms(2026, 1, 10, 10), _metrics(liked=100, comment=5))]} + + rows = compute_daily_rows(series, {}, {}, [day]) + + assert rows[0]["liked_count_delta"] == 100 + assert rows[0]["comment_count_delta"] == 5 + + def test_growth_is_split_across_days(self): + series = { + "n1": [ + (_ms(2026, 1, 10, 10), _metrics(liked=100)), + (_ms(2026, 1, 11, 10), _metrics(liked=300)), + ] + } + + rows = compute_daily_rows(series, {}, {}, [date(2026, 1, 10), date(2026, 1, 11)]) + + # Day 1: 0 -> 100. Day 2: 100 -> 300. + assert [row["liked_count_delta"] for row in rows] == [100, 200] + + def test_day_without_a_snapshot_reports_no_growth(self): + series = { + "n1": [ + (_ms(2026, 1, 10, 10), _metrics(liked=100)), + (_ms(2026, 1, 12, 10), _metrics(liked=400)), + ] + } + days = [date(2026, 1, 10), date(2026, 1, 11), date(2026, 1, 12)] + + rows = compute_daily_rows(series, {}, {}, days) + + # The note was not crawled on the 11th, so nothing is claimed for it. + assert [row["liked_count_delta"] for row in rows] == [100, 0, 300] + + def test_deltas_aggregate_across_notes(self): + series = { + "n1": [ + (_ms(2026, 1, 10, 10), _metrics(liked=100)), + (_ms(2026, 1, 11, 10), _metrics(liked=150)), + ], + "n2": [ + (_ms(2026, 1, 10, 10), _metrics(liked=10)), + (_ms(2026, 1, 11, 10), _metrics(liked=40)), + ], + } + + rows = compute_daily_rows(series, {}, {}, [date(2026, 1, 10), date(2026, 1, 11)]) + + assert [row["liked_count_delta"] for row in rows] == [110, 80] + + def test_unparseable_metric_names_the_offending_field(self): + """A NULL count makes the delta unknown; it must not be reported as 0.""" + series = { + "n1": [ + (_ms(2026, 1, 10, 10), _metrics(liked=100, comment=None)), + (_ms(2026, 1, 11, 10), _metrics(liked=200, comment=None)), + ] + } + + rows = compute_daily_rows(series, {}, {}, [date(2026, 1, 11)]) + + # Naming the field is actionable; a bare boolean is not. + assert rows[0]["partial_metrics"] == ["comment_count"] + # The parseable metric is still summed correctly. + assert rows[0]["liked_count_delta"] == 100 + + def test_unknown_value_only_taints_the_days_it_touches(self): + series = { + "n1": [ + (_ms(2026, 1, 10, 10), _metrics(liked=None)), + (_ms(2026, 1, 11, 10), _metrics(liked=50)), + (_ms(2026, 1, 12, 10), _metrics(liked=90)), + ] + } + days = [date(2026, 1, 10), date(2026, 1, 11), date(2026, 1, 12)] + + rows = compute_daily_rows(series, {}, {}, days) + + # Day 12 compares two known values, so it is clean. + assert [row["partial_metrics"] for row in rows] == [ + ["liked_count"], + ["liked_count"], + [], + ] + assert rows[2]["liked_count_delta"] == 40 + + def test_new_content_counts_come_from_the_day_maps(self): + rows = compute_daily_rows( + {}, + {date(2026, 1, 10): 3}, + {date(2026, 1, 10): 7}, + [date(2026, 1, 10), date(2026, 1, 11)], + ) + + assert rows[0]["new_notes"] == 3 + assert rows[0]["new_comments"] == 7 + assert rows[1]["new_notes"] == 0 + + +# -------------------------------------------------------------------------- +# DB-backed report + task filtering +# -------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def db(): + engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool) + async with engine.begin() as conn: + await conn.run_sync(MonitorBase.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + yield session + await engine.dispose() + + +async def _seed_task(db: AsyncSession, name: str) -> MonitorTask: + task = MonitorTask( + name=name, platform="xhs", mode=MODE_CREATOR, enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=True, + max_comments_count=50, run_timeout_seconds=3600, + notify_enabled=False, created_at=0, updated_at=0, + ) + db.add(task) + await db.flush() + return task + + +async def _seed_note_with_metrics( + db: AsyncSession, task: MonitorTask, note_id: str, samples +) -> None: + db.add( + MonitorNote( + task_id=task.id, note_id=note_id, title=note_id, note_url="", + cover="", creator_hash="", source_kind="", published_at=None, + first_seen_run_id=1, first_seen_at=samples[0][0], + last_seen_run_id=len(samples), last_seen_at=samples[-1][0], + ) + ) + for run_id, (ts, liked) in enumerate(samples, start=1): + db.add( + MonitorNoteMetric( + task_id=task.id, note_id=note_id, run_id=run_id, captured_at=ts, + liked_count=liked, comment_count=0, collected_count=0, share_count=0, + raw_liked_count=str(liked), raw_comment_count="0", + raw_collected_count="0", raw_share_count="0", + ) + ) + + +class TestBuildReport: + @pytest.mark.asyncio + async def test_totals_and_rows(self, db): + task = await _seed_task(db, "t1") + await _seed_note_with_metrics( + db, task, "n1", + [(_ms(2026, 1, 10, 10), 100), (_ms(2026, 1, 11, 10), 250)], + ) + await db.commit() + + result = await build_report(db, [task.id], date(2026, 1, 10), date(2026, 1, 11)) + + assert result["totals"]["liked_count_delta"] == 250 + assert len(result["rows"]) == 2 + assert result["note_count"] == 1 + + @pytest.mark.asyncio + async def test_task_selection_isolates_the_report(self, db): + """The whole point: a report for a chosen subset must exclude the rest.""" + kept = await _seed_task(db, "kept") + other = await _seed_task(db, "other") + await _seed_note_with_metrics(db, kept, "n1", [(_ms(2026, 1, 10, 10), 100)]) + await _seed_note_with_metrics(db, other, "n2", [(_ms(2026, 1, 10, 10), 999)]) + await db.commit() + + only_kept = await build_report(db, [kept.id], date(2026, 1, 10), date(2026, 1, 10)) + assert only_kept["totals"]["liked_count_delta"] == 100 + assert only_kept["note_count"] == 1 + + both = await build_report(db, [kept.id, other.id], date(2026, 1, 10), date(2026, 1, 10)) + assert both["totals"]["liked_count_delta"] == 1099 + + @pytest.mark.asyncio + async def test_no_task_filter_covers_everything(self, db): + first = await _seed_task(db, "a") + second = await _seed_task(db, "b") + await _seed_note_with_metrics(db, first, "n1", [(_ms(2026, 1, 10, 10), 10)]) + await _seed_note_with_metrics(db, second, "n2", [(_ms(2026, 1, 10, 10), 20)]) + await db.commit() + + result = await build_report(db, None, date(2026, 1, 10), date(2026, 1, 10)) + + assert result["totals"]["liked_count_delta"] == 30 + assert result["task_ids"] is None + + @pytest.mark.asyncio + async def test_baseline_from_before_the_range_is_used(self, db): + """Growth is measured against the last value before the window opens.""" + task = await _seed_task(db, "t") + await _seed_note_with_metrics( + db, task, "n1", + [(_ms(2026, 1, 5, 10), 1000), (_ms(2026, 1, 10, 10), 1050)], + ) + await db.commit() + + # Report only for the 10th: the delta must be 50, not 1050. + result = await build_report(db, [task.id], date(2026, 1, 10), date(2026, 1, 10)) + + assert result["totals"]["liked_count_delta"] == 50 + + @pytest.mark.asyncio + async def test_empty_range_returns_zeroed_rows(self, db): + result = await build_report(db, None, date(2026, 2, 1), date(2026, 2, 3)) + + assert len(result["rows"]) == 3 + assert result["totals"]["liked_count_delta"] == 0 + assert result["totals"]["new_notes"] == 0 + + @pytest.mark.asyncio + async def test_new_comments_are_counted_by_first_seen_day(self, db): + task = await _seed_task(db, "t") + db.add( + MonitorComment( + task_id=task.id, note_id="n1", comment_id="c1", content="x", + nickname="u", creator_hash="h", create_time=_ms(2026, 1, 9), + like_count=0, sub_comment_count=0, parent_comment_id="", + first_seen_run_id=1, first_seen_at=_ms(2026, 1, 10, 10), + ) + ) + await db.commit() + + result = await build_report(db, [task.id], date(2026, 1, 10), date(2026, 1, 10)) + + assert result["totals"]["new_comments"] == 1 diff --git a/tests/test_monitor_scheduler.py b/tests/test_monitor_scheduler.py new file mode 100644 index 0000000..339a487 --- /dev/null +++ b/tests/test_monitor_scheduler.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_scheduler.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Tests for the monitor scheduler's firing, deferral and recovery rules.""" + +import pytest +import pytest_asyncio +from sqlalchemy import select + +from api.monitor import db as monitor_db +from api.monitor import scheduler as scheduler_module +from api.monitor.models import ( + MODE_CREATOR, + MonitorRun, + MonitorTarget, + MonitorTask, + RUN_INTERRUPTED, + RUN_RUNNING, + RUN_SUCCESS, +) +from api.monitor.scheduler import MonitorScheduler +from api.monitor.settings import set_cookie +from tools.time_util import get_current_timestamp + +MS_PER_MINUTE = 60_000 + + +class FakeCrawlerManager: + """Stands in for the global subprocess singleton.""" + + def __init__(self, busy: bool = False) -> None: + self.busy = busy + + def is_busy(self) -> bool: + return self.busy + + +@pytest_asyncio.fixture +async def db(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + async with monitor_db.get_session() as session: + await set_cookie(session, "web_session=test") + yield monitor_db + await monitor_db.dispose_engine() + + +@pytest_asyncio.fixture +async def executed(monkeypatch): + """Record execute_task calls instead of launching a real crawl.""" + calls: list[tuple[int, str]] = [] + + async def _fake_execute(task_id: int, trigger: str = "manual"): + calls.append((task_id, trigger)) + + monkeypatch.setattr(scheduler_module, "execute_task", _fake_execute) + return calls + + +async def _make_task(next_run_at, enabled: bool = True, interval: int = 60) -> int: + async with monitor_db.get_session() as session: + now = get_current_timestamp() + task = MonitorTask( + name="t", + platform="xhs", + mode=MODE_CREATOR, + enabled=enabled, + interval_minutes=interval, + max_notes_count=20, + enable_comments=True, + max_comments_count=50, + run_timeout_seconds=3600, + next_run_at=next_run_at, + last_status="idle", + created_at=now, + updated_at=now, + ) + session.add(task) + await session.flush() + session.add( + MonitorTarget( + task_id=task.id, + kind=MODE_CREATOR, + external_id="abc123", + xsec_token="", + xsec_source="", + raw_value="abc123", + label="abc123", + enabled=True, + created_at=now, + ) + ) + return task.id + + +async def _get_task(task_id: int) -> MonitorTask: + async with monitor_db.get_session() as session: + return await session.get(MonitorTask, task_id) + + +class TestFiring: + @pytest.mark.asyncio + async def test_due_task_runs_and_advances(self, monkeypatch, db, executed): + monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False)) + past = get_current_timestamp() - MS_PER_MINUTE + task_id = await _make_task(past) + + await MonitorScheduler().tick() + + assert executed == [(task_id, "scheduled")] + task = await _get_task(task_id) + # Fixed-delay: the next fire is measured from now, not from the missed slot. + assert task.next_run_at > get_current_timestamp() + + @pytest.mark.asyncio + async def test_future_task_does_not_run(self, monkeypatch, db, executed): + monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False)) + await _make_task(get_current_timestamp() + 10 * MS_PER_MINUTE) + + await MonitorScheduler().tick() + + assert executed == [] + + @pytest.mark.asyncio + async def test_disabled_task_does_not_run(self, monkeypatch, db, executed): + monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False)) + await _make_task(get_current_timestamp() - MS_PER_MINUTE, enabled=False) + + await MonitorScheduler().tick() + + assert executed == [] + + @pytest.mark.asyncio + async def test_long_outage_coalesces_into_one_run(self, monkeypatch, db, executed): + """A missed schedule fires once, not once per missed interval.""" + monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False)) + # Due two days ago on a 1-hour interval. + await _make_task(get_current_timestamp() - 48 * 60 * MS_PER_MINUTE) + + scheduler = MonitorScheduler() + await scheduler.tick() + await scheduler.tick() + + assert len(executed) == 1 + + +class TestDeferral: + @pytest.mark.asyncio + async def test_busy_crawler_defers_without_advancing(self, monkeypatch, db, executed): + """A manual crawl must not consume the monitor task's slot or lose it.""" + monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=True)) + due_at = get_current_timestamp() - MS_PER_MINUTE + task_id = await _make_task(due_at) + + await MonitorScheduler().tick() + + assert executed == [] + task = await _get_task(task_id) + # Still due, so the next free tick picks it up rather than skipping a cycle. + assert task.next_run_at == due_at + + @pytest.mark.asyncio + async def test_deferred_task_runs_once_crawler_frees_up(self, monkeypatch, db, executed): + fake = FakeCrawlerManager(busy=True) + monkeypatch.setattr(scheduler_module, "crawler_manager", fake) + task_id = await _make_task(get_current_timestamp() - MS_PER_MINUTE) + + scheduler = MonitorScheduler() + await scheduler.tick() + assert executed == [] + + fake.busy = False + await scheduler.tick() + assert executed == [(task_id, "scheduled")] + + +class TestCookieGuard: + @pytest.mark.asyncio + async def test_no_cookie_blocks_run_and_keeps_task_due(self, monkeypatch, db, executed): + """Without a cookie every run would be an auth failure; skip instead.""" + monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False)) + async with monitor_db.get_session() as session: + from api.monitor.settings import cookie_key, delete_setting + + await delete_setting(session, cookie_key("xhs")) + + due_at = get_current_timestamp() - MS_PER_MINUTE + task_id = await _make_task(due_at) + + await MonitorScheduler().tick() + + assert executed == [] + task = await _get_task(task_id) + # Left due so it starts working the moment a cookie is pasted. + assert task.next_run_at == due_at + + +class TestRecovery: + @pytest.mark.asyncio + async def test_running_runs_are_marked_interrupted(self, db): + """A run left 'running' cannot be alive -- its process died with the server.""" + async with monitor_db.get_session() as session: + now = get_current_timestamp() + task = MonitorTask( + name="t", platform="xhs", mode=MODE_CREATOR, enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=True, + max_comments_count=50, run_timeout_seconds=3600, + next_run_at=now, last_status="running", created_at=now, updated_at=now, + ) + session.add(task) + await session.flush() + session.add( + MonitorRun( + task_id=task.id, trigger="scheduled", status=RUN_RUNNING, + phase=MODE_CREATOR, save_data_path="", queued_at=now, not_before=0, + started_at=now, max_comments_count=50, + ) + ) + + await MonitorScheduler().recover() + + async with monitor_db.get_session() as session: + run = await session.scalar(select(MonitorRun)) + assert run.status == RUN_INTERRUPTED + assert run.finished_at is not None + + @pytest.mark.asyncio + async def test_completed_runs_are_left_alone(self, db): + async with monitor_db.get_session() as session: + now = get_current_timestamp() + task = MonitorTask( + name="t", platform="xhs", mode=MODE_CREATOR, enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=True, + max_comments_count=50, run_timeout_seconds=3600, + next_run_at=now, last_status="success", created_at=now, updated_at=now, + ) + session.add(task) + await session.flush() + session.add( + MonitorRun( + task_id=task.id, trigger="scheduled", status=RUN_SUCCESS, + phase=MODE_CREATOR, save_data_path="", queued_at=now, not_before=0, + started_at=now, finished_at=now, max_comments_count=50, + ) + ) + + await MonitorScheduler().recover() + + async with monitor_db.get_session() as session: + run = await session.scalar(select(MonitorRun)) + assert run.status == RUN_SUCCESS diff --git a/tests/test_platforms.py b/tests/test_platforms.py new file mode 100644 index 0000000..a079ed0 --- /dev/null +++ b/tests/test_platforms.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_platforms.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Platform capability matrix, platform scoping, and per-platform settings.""" + +import httpx +import pytest +import pytest_asyncio +from sqlalchemy import text + +from api.main import app +from api.monitor import db as monitor_db +from api.monitor import platforms +from api.monitor.models import MonitorTask + +XHS_TARGET = "5f58bd990000000001003753" + + +@pytest_asyncio.fixture +async def client(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + await monitor_db.dispose_engine() + + +class TestCapabilityMatrix: + @pytest.mark.asyncio + async def test_matrix_is_exposed_to_the_ui(self, client): + body = (await client.get("/api/config/platforms")).json() + by_value = {p["value"]: p for p in body["platforms"]} + + assert set(by_value) == {"xhs", "dy", "ks", "bili", "wb", "tieba", "zhihu"} + # Every entry must say whether monitoring is actually wired up -- this is + # what stops the UI offering a platform that can never produce data. + assert all("monitor_wired" in p for p in body["platforms"]) + assert by_value["xhs"]["monitor_wired"] is True + assert by_value["dy"]["monitor_wired"] is False + + @pytest.mark.asyncio + async def test_metrics_are_per_platform_and_labelled(self, client): + body = (await client.get("/api/config/platforms")).json() + by_value = {p["value"]: p for p in body["platforms"]} + + # Bilibili has play count and danmaku; Xiaohongshu has neither. + assert "video_play_count" in by_value["bili"]["metrics"] + assert "video_danmaku" in by_value["bili"]["metrics"] + assert "video_play_count" not in by_value["xhs"]["metrics"] + + # Every metric shown to a user must have a human label. + for capability in body["platforms"]: + for metric in capability["metrics"]: + assert capability["metric_labels"][metric] + + def test_unknown_platform_is_not_monitor_wired(self): + assert platforms.is_known("xhs") is True + assert platforms.is_known("myspace") is False + assert platforms.is_monitor_wired("myspace") is False + + +class TestTaskCreationGuard: + @pytest.mark.asyncio + async def test_unwired_platform_is_rejected_with_an_explanation(self, client): + """Accepting it would create a task that silently never produces data.""" + response = await client.post( + "/api/monitor/tasks", + json={"name": "抖音任务", "mode": "creator", "platform": "dy", "targets": ["x"]}, + ) + assert response.status_code == 400 + detail = response.json()["detail"] + assert "抖音" in detail + assert "尚未接通" in detail + + @pytest.mark.asyncio + async def test_unknown_platform_is_rejected(self, client): + response = await client.post( + "/api/monitor/tasks", + json={"name": "x", "mode": "creator", "platform": "myspace", "targets": ["x"]}, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_no_task_row_is_created_when_rejected(self, client): + await client.post( + "/api/monitor/tasks", + json={"name": "抖音任务", "mode": "creator", "platform": "dy", "targets": ["x"]}, + ) + assert (await client.get("/api/monitor/tasks")).json()["tasks"] == [] + + @pytest.mark.asyncio + async def test_xhs_still_works_and_is_the_default(self, client): + explicit = await client.post( + "/api/monitor/tasks", + json={"name": "显式", "mode": "creator", "platform": "xhs", "targets": [XHS_TARGET]}, + ) + assert explicit.status_code == 201 + + defaulted = await client.post( + "/api/monitor/tasks", + json={"name": "默认", "mode": "creator", "targets": [XHS_TARGET]}, + ) + assert defaulted.status_code == 201 + + tasks = (await client.get("/api/monitor/tasks")).json()["tasks"] + assert {t["platform"] for t in tasks} == {"xhs"} + + +class TestPlatformScoping: + async def _seed_two_platforms(self, client): + """One real XHS task plus a Douyin task inserted directly, since the API + refuses to create the latter.""" + await client.post( + "/api/monitor/tasks", + json={"name": "小红书任务", "mode": "creator", "targets": [XHS_TARGET]}, + ) + async with monitor_db.get_session() as session: + session.add( + MonitorTask( + name="抖音任务", platform="dy", mode="creator", enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=True, + max_comments_count=50, run_timeout_seconds=3600, + notify_enabled=False, created_at=0, updated_at=0, + ) + ) + + @pytest.mark.asyncio + async def test_tasks_are_filtered_by_platform(self, client): + await self._seed_two_platforms(client) + + all_tasks = (await client.get("/api/monitor/tasks")).json()["tasks"] + assert len(all_tasks) == 2 + + xhs_only = (await client.get("/api/monitor/tasks", params={"platform": "xhs"})).json() + assert [t["name"] for t in xhs_only["tasks"]] == ["小红书任务"] + + dy_only = (await client.get("/api/monitor/tasks", params={"platform": "dy"})).json() + assert [t["name"] for t in dy_only["tasks"]] == ["抖音任务"] + + @pytest.mark.asyncio + async def test_overview_is_scoped(self, client): + await self._seed_two_platforms(client) + + assert (await client.get("/api/monitor/overview")).json()["tasks"] == 2 + assert ( + await client.get("/api/monitor/overview", params={"platform": "xhs"}) + ).json()["tasks"] == 1 + + @pytest.mark.asyncio + async def test_a_platform_with_no_tasks_yields_empty_not_everything(self, client): + """An empty task set must not degrade into "no filter".""" + await self._seed_two_platforms(client) + + body = (await client.get("/api/monitor/notes", params={"platform": "bili"})).json() + assert body["notes"] == [] + + report = ( + await client.get("/api/monitor/report", params={"platform": "bili"}) + ).json() + assert report["totals"]["liked_count_delta"] == 0 + assert report["note_count"] == 0 + + +class TestPerPlatformSettings: + @pytest.mark.asyncio + async def test_each_platform_keeps_its_own_values(self, client): + await client.put( + "/api/settings", + params={"platform": "xhs"}, + json={"platform.xhs.crawl_sleep_sec": 3}, + ) + await client.put( + "/api/settings", + params={"platform": "dy"}, + json={"platform.dy.crawl_sleep_sec": 9}, + ) + + xhs = (await client.get("/api/settings", params={"platform": "xhs"})).json() + dy = (await client.get("/api/settings", params={"platform": "dy"})).json() + + assert xhs["values"]["platform.xhs.crawl_sleep_sec"] == 3 + assert dy["values"]["platform.dy.crawl_sleep_sec"] == 9 + + @pytest.mark.asyncio + async def test_system_settings_are_shared_across_platforms(self, client): + await client.put( + "/api/settings", + params={"platform": "xhs"}, + json={"system.active_hours_start": 8}, + ) + + dy = (await client.get("/api/settings", params={"platform": "dy"})).json() + assert dy["values"]["system.active_hours_start"] == 8 + # ...and the system specs are present in every platform's response. + assert "system.active_hours_end" in dy["values"] + + @pytest.mark.asyncio + async def test_a_key_for_another_platform_is_rejected(self, client): + """Writing xhs's key while scoped to dy would land somewhere unexpected.""" + response = await client.put( + "/api/settings", + params={"platform": "dy"}, + json={"platform.xhs.crawl_sleep_sec": 5}, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_cookies_are_per_platform(self, client): + await client.post( + "/api/monitor/cookie", + params={"platform": "xhs"}, + json={"cookie": "web_session=xhs-secret"}, + ) + + xhs = (await client.get("/api/monitor/cookie", params={"platform": "xhs"})).json() + dy = (await client.get("/api/monitor/cookie", params={"platform": "dy"})).json() + + assert xhs["present"] is True + assert dy["present"] is False + + # The old endpoint still defaults to Xiaohongshu. + assert (await client.get("/api/monitor/cookie")).json()["present"] is True + + +class TestLegacyKeyMigration: + @pytest.mark.asyncio + async def test_old_flat_keys_are_moved_to_the_new_namespace(self, tmp_path): + """Existing installs must not lose their cookie on upgrade.""" + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + + async with monitor_db.get_engine().begin() as conn: + await conn.execute( + text( + "INSERT INTO monitor_setting (key, value, updated_at) " + "VALUES ('xhs_cookie', 'web_session=legacy', 1)" + ) + ) + await conn.execute( + text( + "INSERT INTO monitor_setting (key, value, updated_at) " + "VALUES ('wecom_webhook', 'https://qyapi.weixin.qq.com/x', 1)" + ) + ) + + # Re-running init performs the rename. + await monitor_db.init_db() + + async with monitor_db.get_engine().begin() as conn: + rows = dict( + (await conn.execute(text("SELECT key, value FROM monitor_setting"))).all() + ) + + assert rows.get("platform.xhs.cookie") == "web_session=legacy" + assert rows.get("system.wecom_webhook") == "https://qyapi.weixin.qq.com/x" + assert "xhs_cookie" not in rows + assert "wecom_webhook" not in rows + + await monitor_db.dispose_engine() + + @pytest.mark.asyncio + async def test_migration_is_idempotent_and_keeps_the_newer_value(self, tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + + async with monitor_db.get_engine().begin() as conn: + await conn.execute( + text( + "INSERT INTO monitor_setting (key, value, updated_at) VALUES " + "('platform.xhs.cookie', 'current', 2), ('xhs_cookie', 'stale', 1)" + ) + ) + + await monitor_db.init_db() + + async with monitor_db.get_engine().begin() as conn: + rows = dict( + (await conn.execute(text("SELECT key, value FROM monitor_setting"))).all() + ) + + assert rows.get("platform.xhs.cookie") == "current" + assert "xhs_cookie" not in rows + + await monitor_db.dispose_engine() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..40c3eed --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_settings.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Unified settings endpoint, and the effect its values actually have.""" + +from datetime import datetime + +import httpx +import pytest +import pytest_asyncio + +from api.main import app +from api.monitor import app_settings, db as monitor_db +from api.monitor import scheduler as scheduler_module +from api.monitor.scheduler import MonitorScheduler +from api.monitor.settings import get_setting + +SECRET_VALUE = "web_session=SUPERSECRET; a1=abc" + + +@pytest_asyncio.fixture +async def client(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + await monitor_db.dispose_engine() + + +class TestReadSettings: + @pytest.mark.asyncio + async def test_returns_values_secrets_and_the_spec(self, client): + body = (await client.get("/api/settings")).json() + + assert "values" in body and "secrets" in body and "specs" in body + # The spec drives the UI form, so every key must be described. + spec_keys = {spec["key"] for spec in body["specs"]} + assert "platform.xhs.default_interval_minutes" in spec_keys + assert "platform.xhs.enable_ip_proxy" in spec_keys + + @pytest.mark.asyncio + async def test_unset_values_fall_back_to_spec_defaults(self, client): + values = (await client.get("/api/settings")).json()["values"] + assert values["platform.xhs.default_interval_minutes"] == 360 + assert values["platform.xhs.enable_ip_proxy"] is False + + @pytest.mark.asyncio + async def test_secrets_are_masked_never_returned(self, client): + await client.put("/api/settings", json={"platform.xhs.cookie": SECRET_VALUE}) + + response = await client.get("/api/settings") + assert SECRET_VALUE not in response.text + + secret = response.json()["secrets"]["platform.xhs.cookie"] + assert secret["present"] is True + assert secret["length"] == len(SECRET_VALUE) + + +class TestUpdateSettings: + @pytest.mark.asyncio + async def test_partial_update_leaves_other_keys_alone(self, client): + await client.put( + "/api/settings", + json={"platform.xhs.default_interval_minutes": 120, "platform.xhs.cookie": SECRET_VALUE}, + ) + + # A form that only submits the interval must not blank the cookie. + await client.put("/api/settings", json={"platform.xhs.default_interval_minutes": 240}) + + body = (await client.get("/api/settings")).json() + assert body["values"]["platform.xhs.default_interval_minutes"] == 240 + assert body["secrets"]["platform.xhs.cookie"]["present"] is True + + @pytest.mark.asyncio + async def test_empty_string_clears_a_secret(self, client): + await client.put("/api/settings", json={"platform.xhs.cookie": SECRET_VALUE}) + await client.put("/api/settings", json={"platform.xhs.cookie": ""}) + + assert (await client.get("/api/settings")).json()["secrets"]["platform.xhs.cookie"][ + "present" + ] is False + + @pytest.mark.asyncio + async def test_unknown_key_is_rejected(self, client): + response = await client.put("/api/settings", json={"nope.not.a.setting": 1}) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_out_of_range_is_rejected(self, client): + response = await client.put( + "/api/settings", json={"platform.xhs.default_interval_minutes": 1} + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_invalid_choice_is_rejected(self, client): + response = await client.put("/api/settings", json={"platform.xhs.proxy_provider": "nonsense"}) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_bools_accept_the_ui_shapes(self, client): + for raw in (True, "true", "1", "yes"): + response = await client.put("/api/settings", json={"platform.xhs.enable_ip_proxy": raw}) + assert response.status_code == 200 + assert (await client.get("/api/settings")).json()["values"][ + "platform.xhs.enable_ip_proxy" + ] is True + + @pytest.mark.asyncio + async def test_password_hash_cannot_be_written_through_this_endpoint(self, client): + """It has its own authenticated endpoint; this must not be a back door.""" + await client.put("/api/settings", json={"auth_password_hash": "pbkdf2_sha256$1$a$b"}) + + async with monitor_db.get_session() as session: + assert await get_setting(session, "auth_password_hash") is None + + +class TestSettingsActuallyTakeEffect: + @pytest.mark.asyncio + async def test_new_tasks_use_the_configured_defaults(self, client): + await client.put( + "/api/settings", + json={ + "platform.xhs.default_interval_minutes": 120, + "platform.xhs.default_max_notes": 7, + "platform.xhs.default_max_comments": 33, + }, + ) + + await client.post( + "/api/monitor/tasks", + json={"name": "用默认值", "mode": "creator", "targets": ["5f58bd990000000001003753"]}, + ) + + task = (await client.get("/api/monitor/tasks")).json()["tasks"][0] + assert task["interval_minutes"] == 120 + assert task["max_notes_count"] == 7 + assert task["max_comments_count"] == 33 + + @pytest.mark.asyncio + async def test_explicit_values_still_win_over_defaults(self, client): + await client.put("/api/settings", json={"platform.xhs.default_interval_minutes": 120}) + + await client.post( + "/api/monitor/tasks", + json={ + "name": "显式值", + "mode": "creator", + "interval_minutes": 720, + "targets": ["5f58bd990000000001003753"], + }, + ) + + task = (await client.get("/api/monitor/tasks")).json()["tasks"][0] + assert task["interval_minutes"] == 720 + + +class TestRunnerAppliesStrategy: + @pytest.mark.asyncio + async def test_strategy_settings_reach_the_command(self, client): + """Stored settings must actually change how the crawler is invoked.""" + from api.services.crawler_manager import CrawlerManager + from api.schemas import CrawlerStartRequest, PlatformEnum, CrawlerTypeEnum + + await client.put( + "/api/settings", + json={ + "platform.xhs.crawl_sleep_sec": 7, + "platform.xhs.enable_sub_comments": True, + "platform.xhs.enable_ip_proxy": True, + "platform.xhs.proxy_provider": "static", + "platform.xhs.proxy_pool_count": 5, + "platform.xhs.static_proxy_url": "http://127.0.0.1:8888", + }, + ) + + async with monitor_db.get_session() as session: + strategy = await scheduler_module.app_settings.get_value( + session, "crawl_sleep_sec", "xhs", 2 + ) + assert strategy == 7 + + # And the flag builder forwards them when present. + command = CrawlerManager()._build_command( + CrawlerStartRequest( + platform=PlatformEnum.XHS, + crawler_type=CrawlerTypeEnum.CREATOR, + creator_ids="abc", + crawler_max_sleep_sec=7, + enable_ip_proxy=True, + ip_proxy_provider_name="static", + ip_proxy_pool_count=5, + static_proxy_url="http://127.0.0.1:8888", + ) + ) + joined = " ".join(command) + assert "--crawler_max_sleep_sec 7" in joined + assert "--enable_ip_proxy true" in joined + assert "--ip_proxy_provider_name static" in joined + assert "--static_proxy_url http://127.0.0.1:8888" in joined + + +def _frozen_clock(hour: int): + """Stand-in for the datetime class whose now() is pinned to a given hour. + + Testing an hour window by sleeping is not an option; patching the class the + scheduler imported is the whole mechanism. + """ + + class _Frozen: + @staticmethod + def now(tz=None): + return datetime(2026, 1, 1, hour) + + return _Frozen + + +class TestManualCrawlCookieFallback: + """The crawl page no longer has its own paste box; it reuses Settings.""" + + @pytest_asyncio.fixture + async def captured(self, monkeypatch): + # api.services re-exports the singleton instance, not the module. + from api.services import crawler_manager + + seen: dict = {} + + async def _fake_start(request, extra_args=None): + seen["cookies"] = request.cookies + return True + + monkeypatch.setattr(crawler_manager, "start", _fake_start) + return seen + + @pytest.mark.asyncio + async def test_falls_back_to_the_stored_cookie(self, client, captured): + await client.put( + "/api/settings", json={"platform.xhs.cookie": "web_session=stored"} + ) + + response = await client.post( + "/api/crawler/start", + json={ + "platform": "xhs", + "login_type": "cookie", + "crawler_type": "creator", + "creator_ids": "abc", + }, + ) + + assert response.status_code == 200 + assert captured["cookies"] == "web_session=stored" + + @pytest.mark.asyncio + async def test_an_explicit_cookie_still_wins(self, client, captured): + await client.put( + "/api/settings", json={"platform.xhs.cookie": "web_session=stored"} + ) + + await client.post( + "/api/crawler/start", + json={ + "platform": "xhs", + "login_type": "cookie", + "crawler_type": "creator", + "creator_ids": "abc", + "cookies": "web_session=explicit", + }, + ) + + assert captured["cookies"] == "web_session=explicit" + + @pytest.mark.asyncio + async def test_missing_cookie_is_a_clear_error_not_a_silent_failure( + self, client, captured + ): + """Better a 400 that names the fix than a run that fetches nothing.""" + response = await client.post( + "/api/crawler/start", + json={ + "platform": "xhs", + "login_type": "cookie", + "crawler_type": "creator", + "creator_ids": "abc", + }, + ) + + assert response.status_code == 400 + assert "设置" in response.json()["detail"] + assert "cookies" not in captured + + @pytest.mark.asyncio + async def test_the_cookie_is_read_per_platform(self, client, captured): + await client.put( + "/api/settings", + params={"platform": "xhs"}, + json={"platform.xhs.cookie": "web_session=xhs-only"}, + ) + + # Douyin has no stored cookie, so it must not borrow Xiaohongshu's. + response = await client.post( + "/api/crawler/start", + json={ + "platform": "dy", + "login_type": "cookie", + "crawler_type": "creator", + "creator_ids": "abc", + }, + ) + + assert response.status_code == 400 + + +class TestActiveHours: + """The window gate lives in the scheduler, not the crawler.""" + + @pytest.mark.asyncio + async def test_inside_a_daytime_window(self, client, monkeypatch): + await client.put( + "/api/settings", + json={"system.active_hours_start": 8, "system.active_hours_end": 22}, + ) + monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(12)) + + async with monitor_db.get_session() as session: + assert await MonitorScheduler()._within_active_hours(session) is True + + @pytest.mark.asyncio + async def test_outside_a_daytime_window(self, client, monkeypatch): + await client.put( + "/api/settings", + json={"system.active_hours_start": 8, "system.active_hours_end": 22}, + ) + monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(3)) + + async with monitor_db.get_session() as session: + assert await MonitorScheduler()._within_active_hours(session) is False + + @pytest.mark.asyncio + async def test_window_wrapping_past_midnight(self, client, monkeypatch): + await client.put( + "/api/settings", + json={"system.active_hours_start": 22, "system.active_hours_end": 6}, + ) + + for hour, expected in ((23, True), (3, True), (12, False)): + monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(hour)) + async with monitor_db.get_session() as session: + assert await MonitorScheduler()._within_active_hours(session) is expected + + @pytest.mark.asyncio + async def test_default_window_covers_the_whole_day(self, client, monkeypatch): + for hour in (0, 12, 23): + monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(hour)) + async with monitor_db.get_session() as session: + assert await MonitorScheduler()._within_active_hours(session) is True diff --git a/webui/index.html b/webui/index.html index 722c324..4b25344 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ - MediaCrawler - Command Center + 综合采集平台 diff --git a/webui/public/logos/bilibili_logo.png b/webui/public/logos/bilibili_logo.png deleted file mode 100644 index 7fce00af13b7ba06c7160ab343e2cbdbb057228d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43010 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 z 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≫tHlCfsf;r0HxtK0Y#}W{U+Nd7#(n1xrPM6WkP9I`e6qT8yh1At*RX=F5+d z&(G#Js}IhUaU3nz4QSkOGJ*tYc~gHeBK;?KY+jVV6$_;BxCkDyMsr=)Z>w@o_grYe zRDo{cOLBr>L3>{a8m_@^(5Cb6^tX>-?tKSy^P z@EPqnJSdtT0$E*{EtUSF@XZ*g9!KMQY?(&HN%8)YC->EJrvq4z6bv&Klon>hKu#+d zfIwS>SPSR{uri+oapF^Eg81+=nh}simjw8=Xr#R8Do2^BZf2A5FKSi|R{7HER1tEP zpOjnfEz1@Ev~i+zF*Oi^sBal3g!3BpTmnVVObhP4U``}I#jyIxM2}<@r(`!NMRKCz zDhAcdRSiEBCT)r>rxnPI6BR9P#lfkf?r}*?@>GqfI`Fi(bVu(;z7!V?gGEC));S1J zIWA8MxDVo#aQ_rqdu*ZZ0gi@=&tvR_dZ>|(wIk>TfS6k zUFz;GU1h4=l)!kOqwSq%P=D&^E92G^LY;*jSlxm=O-VQSC^*0jIAm`@YU^& zjRkMpkt-o`9FPR0Ye+W|kcXm__D%UQph<%i+<7~5u2)Z=9s67t^SWGK_@Y<9F255B zMZ#bwP+=JxUXT@_u$<2iM*ClUv~NiF{kaj5i>*!G(M}e{Imt{_;aYaCXdk0bs*gMu zj8$Bynu6CM?P-H2NsZeaFeSWy|9-UhOq%98vczCbq7v*3!J)QLkEANoWU(Bvu+4&0 z;lV^Im9?5mwuw6KWKP*=MoSO?6csljz&wPoutL&FAZtZMYm^#Jb3SCvoR%at<)<=i zP*VPJ$_D-NSHScwQA?t-2~2IGB@o zu3}moCNjeXYy)vz;_MaJIKYc}TQdW^6qTY86st++WlZNh%mx zjqHv9qn5-g!ccI<$kK4eQ375}l9dH7GNUVzW2Q@=pqZxLn40y6*N=`Sh8e6o2qWxH zj(G7DO;}&A942tIc{-i?_8t!xDM?+1Zz6qbE<_eH>MdTM4`6v?+zM*i#@*X17W`)M z)+xb0#B!VU;rE6D^kHIf2_pByBQR~I3N9JbSe-xJ-?@5raJ72#_QD1~LosOycv2BW z3CK|biOQl%Ju^L?P_Fd972c@Vh!*CRG*;aY{Ef_rClJA%AhmaD zO+=YIWFtALw`8MUxlNkFGyTk5YaG@lHB&jc|l@ zOi+vwX6F6f%E)UhiBuFLy>UOGf#gPq(Z@0z+48rxI0{>;FWF%tpV^OKktRGP#E^w> z6e5~aI5zOh=}Ft1x0Y?$&lI$X->u1hk;zn{N(YA;(g3ex*YENiLIl#N01%SCq^h(~ zt!2imNbmegD?L4ymBk2i3eh5bfsn!*m?^)8ky02G(7ORwNvN6n)S6Nw>4lXnj4-sL zZvJ_Z2uXrg(C`z}--5XtJ?#)7R=Z+@hg>n#Rf=301WzdHr_z+wu8Bp*+5+!NR|Cp@ zfQOifXoYGWf}E$6b{`sWk-28?B3NMYx?S>-vNLas| zV~oS1xTTS|G=~FPmK{P=Msc=_c*0XSmP$_v?2Mj)&}0KmxzB~l3~h|H~-)e>ioo}O1`|I@k)$-;=0uC>le z6{_e+k)cWxW$D!4-M~!eW4AZBTwI)=Uz+nb=%jWGM;d&bqt=n6 z$z(NZpsXMSua$X`@d$P>jUp-thOq)a39Tstb1ZDu{Qb_`BAvmB$Wp^w3^W6fKqVEK z)g(*|v=x$By>$6I3% z#IcA?$>AwQm7q>k4pgcvyO81^7+F2O*l5(L+7Yp(kFBZ}j}MT-GVvIhS<6Hf-{@A( zsnjYDnAH_ZQX!8GsVXX9r|x+4E$;(;+fA`Q2sJyagi02bfB*{T@g8`$c>j=6{l$b_ zXmbo=fGp=7*BucViX!QCj!sxDZdUrvOJbE3qN24F$d(DkczFZ^HEU>G3I#D4N>oP7 zjiVilm_SWLpu#?2mp#gnk0oj_RX8BM6cdrDkmTiBHTxzm5*(iE3aJf6Q|3B#A(Au= zeR+$dDWrw`T2x?$=gz@{TRDi;j)0{vvK}5k+1}-X-5wks&CcI$ZVrr(SenA)lf8v? zA?-))ahca8hmy7Ri%7c{q+)Yj8!F}Z-8v$sU~J-=lFL{TpSUWJf~GaoB*U7kVXxy% zL`S#Grzb}TJ-dxpi|Hk8yWJdY^sV%r&z6J!?!|d$v%Bbz1_aB@cg{yCy-+y%M4mlH zw2LwIh*>rjIJJD}BQ)2G(eBRm;(R#qVIHe^DSHfu5Rl5qD#{J+C`~H`Q#MC}lz(?M z>N!YoczQW{{o&&5J;|8u4tn#PHGxf$MgXk&shlEZD?D}FtD!OxEu2l8nYz=@j3 zvSu2{Ax|L5nqPz2C2g*SMGQpBZ7BGO*^23?x3S!LlVbf;s!$*jmg6Sb(AJD?{qO(h zA8F1b2N4K@%i|5e&O?kvtaw@m>@1ay@`&>f30(72olWi$X|B;hgC82Kv?`Hr&PWoM zWdx1L%;GRzKp`X~lIUcdE?_FFG&;EZ6PLg8Gz~~%iR>_V_>)7~swQv2Es%pS_kdL! zAk#q%O}uLrMe&sDRsF%cs#dB@X;*72Bw|*UoSEvjf*T-KVZD~ zUfrw~7YBPo5L#pk3cv?z{|k3las@(^Z@9RM#O7N|(`FYak#CLd>|D$)r}Oe{w4;Wb z?{3ZUF$!u#6$9U-86+|j6!SuYx)khcC|s>}ghX`AMn*?1af|3hGKDx_q&47KZyS*Y zM4}UyC)em>X#sng!V3#&$~<9Ysw~;dc#UxYn^u&O`sscKqar!P#b#nleT$X8Ly=LA z@b_jqzx?FmkI^=BMmN{T$A`QS>vGdxH%`k^gi;{^RKN0qY?LjMAO>O#Qhk{_YsHmR z3TlurpCwPxx{w)I(qaP0Nz*YhAw#H8hK|fBkF~kAF3@R8xs3&iElPKi428&vGYDjc zO}=b&QVKZ(K^SQ~Qr{aMD5|*%4M86#Fg$Wiu6BAW^_C{FDM7BZLf)ysg*RdWRv6$p z?Vh}Bh?UrQ6ZS;58}NaTdPKqf58msm_ZXv!b;vsnQ0rI(dbzwx{uEVmli3di)EHuU zTX8Y9EHW|81+WA`xePo_P(^y18k>TCJ@!-rQN;$wroFP$>ZC0lNHrxrkzNd|$W}#5 z<4Unz8=TN`6HYoQ;NBwB!>D4Z0O$ftD1~}CL^flW!SjZyQBsbm5(7-Cv%4SFvg?R@ zm)b+n3}*&Bk{dY}9}#JwiYIb;8zYNBACIte6>?Mzg99uP(WWdc_^heRcx`mwq1R=zehNxpD8T?z z2oN%cm>vVWkoS=sDRZqGX|OBkGwWy~?M?ff3DwCX;61TmHk z+F3Zz*mPC#;QH>-@z(N(n;D1p&Q9!{G)bu9P-Q3F@*akCJR<0-$^4fy62Z{v`7XlE z@^(uzX+f?AW0&p$E72_}vTQ)u?z3lf%y4DyYxp7&DYnkI@H)*sP~?=X408mcybZ}hkx&P( zB(Ic3vLrxeq!H+QQ%Gri3{KA&z)<&FI&Iy8I-{&;S%pfj1aw_50sw&bfJvbNsU>bm zNU3%rqPlppAwd;|LP=bfTWkm%1;U(&L-cPOXUnxLrn zmBy?45Gesk)p#H0_eYw96hC>iobfN7F;_38Z-z{?3B597^4@sxGD-ybyYsYAONGa5pF%k zV7HJy24vR~LRW)&$_;scLY4dU0L?4`c@QI4u`%iO6tZdxW(GozpFDp3`n6pEY?f)f zY-hBeNh4h&7rtP_9q9J@1S51%P+f}>;cE$ZY>@7sQ*l%bV-n-q6TKzNA`?9`(qQ^Xuiu&!4_}{rzHk zz0-5RsSkYQ8$719VuDw`IJ+A~!VsQClY13PfczVs@v!Tw2Mm@E5Gb5@s2RNgDHIdd zG6w$$KoPXz+c4ej?=p@leB;V#;-7u=c)5DDc&V$r%4)fd%@tqG4Bl=Zx%7H;)l+f| zMf3=^)Dt|$a^;c6CM*2a;CbwA_WXvPF+W)@^-y(Y3nEC-+;E|Uhp5Gsi%5Ye3esdv zwva$0Rx68Wte2P--ApLfs-O^8mElf1jHk16os#p5 z#?4p?f3fy~2qi&8#sl2C6jdiOvQ!~A-h;VPfSe>mbj8c!8q|nKjUBI5ptz8Xl?(M^ zsy$XwsZxEhD{GEzqi(A{wzq}gRw@bIsLDDZ26zz$jYO?QAVwgBK!O&_Fz6aJ5XJjm zN(~*B?9|#|TFHF|7Et6ac86a?JW`bg=K+oaD)p;8IF#Z;xUdS4QGKt2VR0z;VN6O0 zt0YCGN|Feh?%msZlt7X?LGxns7J@jknF1O zKCzcoa$u6nm65&A3;Vn!-0M;##xF@YRoT#z4Ym3uptS2=F8)P23i00J#sW1WU4i&` z77uSA)TJmDV;!teFP!!7J_NSph6`H7BO!tXeo(0Y;zLBQ5TFDAz!fQ0(p;)BNUFqF zW8vE9=^81ms{*6XzH`8>G}+bN>`(Uh-o1IJ>$R{$1}ZAo#1sUIA`(JX{X4|>ij@SE zuABrRMFp58{@&CE>aZ_C8Y$aHP%>3rS&V9>FBq$;>nvsLFNo#ikscrIo0Tf()`ApM zvPSouU&M?CGQEZ((9PrH{k!Y$-Rsmk8!Wzf0K-Oh$gW>3N#8KQ#GBA{&FX6}dAmcK zYVYhKvtVG$=gzF;)pV5h1Rv=^-cVShQH^l}UcAV78@d7n;@ZGv_xNylaXGoTI6a@S zx-*?1Ye^mZi!9`Unsw`7m(rlHUL@?*;y6JBP_l+AFt0@BjdI^QC)FSTnIdO~auxQcWn$H-acGQ#0h6I7aU;s|4W7H?5_ zpobW&e*6@r1QEiJ+sc*{*?^nneu^uoywVOeZE1<5jUGk6kj2m1^n~Prt?1Hjl7SQv zuC%|Yrv-EYlOe<%+L$y9etr=j+z1GdSSX%VfNZa{*gHW`PNCW^npP$Xj?#p~DI!Q^ zP{vKs#&ZjWYOs>LKt-rv!#stHNglgU_jyi6Z=#wAr(%3tCtUZ;HQfz3oB(hNE5o3a z!X=1Apb#7jR@s!&KxwtKs}icV|MpV(?t%G9n)Jm=7Rzd^je=?&@xIOi+D34SsD%$$ z=B%08tYx8-o;5FlB{h4$?qn!Xu!ucqm#pBkt=$x$(!xLa0&sGdsx*Tt#$Sr^s_0cB zvu-_5QawfU2x&+XHU)qpvY%v5g5*f~{#OW5QgZ0#26&07K#F?3o%zLVv^Tk$Esl;K zot)aD&New~#zu7~1Jqnx#P`mqg7O-l?+eJUVplbyO6%N9gS_lW?U4js2$HG`DmNbW zD?Ku@P8$&;ad~#eUXzTVi%bYg@!0yA^)kFWLuU@PG&S1b6V$${8#$~^14cQGs$1Pk zFFM*+8Dr(sod6{rtD$lNk8o*QqkCEokJrmv26ng0r8nagARL}*`IQ-&WG&zu4b_$E zTBz2-BQi>ZMANyAUhJ|h$aACVy`9nVL4Q7-ee-H{HnR+E(e-u8ZrAr)Oapa$4xlML zuI9OTPUsTaOqvaN?u}jILg{3P8zu*$D5hFLR}if)AsL|nVR1Z!%|Iq0PD-6E6^M+} z@FQL&XH~$xz$hWNYLCQ}Z0DIk7k~Au^G%`}UI)hI>BW;LPgcvBW#f7i&Q@=3byPm! zp$sXCL*k$$@*RR@RH$@8O>5XA^$ipiX%UKJ<@FV)a3$?!bd(kOe~>E$H1HZRq995r z3dDTjdx#WeQNZ@VunR&eiH;*kYk1@_nBQbC3H?W4nx;MXxs;R#^yDkLxbP^Lq)av? zgy^xY+H7M{929g8lv)gufPE0o%s9BwzLl1r237zHT>?`HbRcUjr1gmkM$4qm525v6 zb!(hcdtgvk#W6TZCwP>&de%uC(;^}jwe(d;Qb`xXPZ)v`Wd*zc=>17&A{pMFb_o#lkj~sFa6A zB{Dsf;Ze#S|ChfQzcWK;LAx@LxFl-$27;0dzfq6S*)Ju|(g*26fBCahq<%dTq`+EK zPh852>bb3uGVcSx#CdY#x=s<^X)!Xh%$=OwTu%1)kB*L$Qn^TY@5T~0ev7X&IQf;@ z2}RzztFq9Om4vvP=&-CFt;5eDu;JG|Ai+(T&8~;T9iLFdI6(}z@#<#5Lnt@Kw#LYF z0~GAzNj8w1-ZF)49M{aod~ONBkTJ3q3~{t+SJzklL4UQnI(pQZP2*5sFP9Opj1Ax! z(;QP+VGTcKMc`xQjTK-we7`3_@)J@&p3ryKk4BF`aZZj6&~>$CJz1IX4`+$yULs-8I_lN;aG_5&kni#=#h{*p$@1 z9yZC`ZN@~?V^^s@e$=3#RrOk@WBQV-1u%rEum}wmj@_+HMHdFev^7;jt&Q!~!enH$ zx^%(&7{h=RBSd!jvna^~1X@y{!HuOZk^n1hT(p_3OqH+*1FAOh;Lq03Soug&Q4pqz zETl^7Q`(yNHT+>#ARVm08zYe(j2~kQLR0%tO@YP1kS5PTI0y;F^ax@RWlf!=KtY{8 z)Q^Xerl$EX$qxWn&^G#!nu~y8RcpI`6pN}M^|hO-t}?7y5ZnWG6QjaQ8tI#Z*6ts! zZpuHRDiKzOG$|_tszD?^axX|DT*a8^U7%w6LlpX z{!(N911rd@D}qxLj;gw=$51+&oo6BR4=r5$mrH$NMEDzV%?qwVaV2n7A?+sl+MnmT z5mR)#vm$5C!n%58XYKP(KQXJ$iPf8BE(v_ew|e4q@lde}UTc!JLaL|LmjKT5g z`?I6tCwSr`w4rl8lXH1E+FQ(4Uwrn-um9=w+-KHVB#Rs+fsA${$HP*8luLIgf?n|B z9okOzdfrA$%MW!VSmaQ)HCGY4U=kIkhij;i`wDW2Y2Ruks@smQF-&^nn~eR*u>ZxU z&u%+!U%avU#23u6|H4pN#teQE8c7kC@{2~F;oa@Y$w?M7RZK?Dt41;r1C<69crh2v z0Ewyu0UJ<$d>&3(%9qD7-!3_@D#MIPCSZ_UkW0DT%2gqXv_QrytRE|{h?Es;8_IZ<_97wir7R*+-}bv7-lGBmZK0LY>PfK(sxm~N zO`)8djNt}?qBw+OGVeC*-=_8HNwJy*L@X(hO|xG^6yPu>E3!H)bSQSNxCM9MWkdmY zAAA{wtc9#dR18aXWETVpWq#ust+`hON^SKH;o|JLs*2piPj{4b)@UR%Ws2EPr$T~` z1!Ac%5fLLlyp5*q=wJ9IwYplMqA_u54$sXONw0o`ahjXJlRrh}Phj6gB!tuS7|F_{ z(mVvC0b!BCLO-`i$Hm_kW4BW?-=WVRB+Cf5g!hYmrQ+Dg^S{&8vm1~*k zkR*WnSEK_vVlHOW!T#j@)K_37=o~$HtgFv>-8#ZLr@e9j=_<)D&5Bq=DkFi#dAKRR z>;-_6h`qL&(x{wuMas;_a*X;H!K3PFOgAT^-lP4!qrH(8N#+bwLrD}$(nxczBt56% znf#%Hcbz@MqxtA=R@Wh#d4FpNySXg}@*=0jlk!=R)7qhY5Mk6GSSP|SLL75^yzC&{s2qF~f@{!?z^FBMC@c&A3GyT* zi}sx-#ud&7CGOJ3SoXL5{Mlm%30^MVygre3+U@OI4TDfw=9@L}5eZHic9a}u=IznM zSLHWWv+3Ez6w+#-!shufATq-MG*(3FV*wP=I2$-yU?Lo)s0}G64dUrvV2N2=^<*|J zI+}3bb1^4#MrSn>|@8BF_`6`LQcvVaZ=+eRFzPrlO$aFRL{zMhkwaf^kb%xrXI9 za%$wVs!K-qBn0G^4CYmiQo*vUIVYm%n@@U`|FRo-Kqk|*ZTfyy>xDzBVp14VO$Kq# z!2#NoG6Do9ZJ!w`=jBH@D5k%7jq5*KC=Cy>Nt#)!hxO}u79FebcYJNsk>vRVVU^Nc zo=6IbfO=%knZG-Z;_ct^TZVhKc@f#`&7B-3()B&$zV23BHIIaqQh=9^nBm(rTxFy? zXof_a+&$>?8i;#!u*zA3FiC_6jLe2Br|bw?5tS+o)0>gaJ_r(~Ojlf>0!X3#Elmi- zkP0xUq4;VZ)aB@9dy2UdgMzv25WH0I!G!x zk<5}cOJ8$zjK!o|qv6;(c>rzS=!q+M5=e>3%1pkG z@Jt3<-Hp31oya@sT05<2OqPYTfA%MT5wOa= zvu+s=NEL|2!;uz;V3eZ3oBc8{b^FDaA7M4sQEFL&@+UWM&fcHt`lVlhqG@4$KmK~F zYXRV3t=6a=f6k|S877y~m(u2PJ+@=2t@ONhGBu)Q&JjV1dGRzIm|IO;^a+z`Z|RuB z>wJ147A$CZD>oIWudlnQY!uyaSL#f=hu)TSca_=t3_3g1)+P zr7*4ia7~)bpC(a|btwYn&i`OfZy3fvjEGU5^v7gWV$~vJd(xhzODLcQ7O<+Zj9FO5 zn7fT86<8+rvTl^wQev_f9Rgdv{}@q<6;ny$SV)?5lrc<9%>kRQ;g6AB)FSfovpB5I zLtdeacTOT4uuOK!_B+#s)EU4*tA1}(O9xlx+VX1D>emBKaIt#DUo+C9}#={3H%oaVxqZ`SBhapi1nPxcQ|Et{X&&D$$3 z?5&x|E2lS-M6#kr@Jka0Z{7ZvzxtapxK{J}vYX@KyRKTE$!KC7!LEVk=v^w>Hsig0 zb$Ja7FkpOIp#F)cwFNMQD9R2Z{r7aaBt-2#c3XI7mlQ|`?6)cb169efc%q7Oy zC2^4vRFOHUs=q5aLm3eYpHfFb>k8=gQ4omA!C6Zi?L}>90QTVsGDx zk)dIVP5+AtijTm^8ajdabar}pyLWIn{Ndyz_L`w`U;?0HP2yY!au7v|fV7qzPaD+GNBr`sHX>%eY z3Xv)yRE-L0J2I9>U`i@WM0B6xgk?oDA4l*ShLnm(+&O|00}A9ca020td1aGPA|cSA zBE^gvhQkrbG@r@V5Q>gwG$x6i${}|?!4(_2lVcVkX&E7gNhO2q^_2l`vGiLbQwLZS zSURBEYk{XZKlI?}NC|;G-N{T@Xg0N- zv&#$7K_xJj36`J^11+S2(!*ORv%3^YE454V`VUY#k=f?@D`lUAo)4RtFq z$@4Y$;1uH*TY*-gkcx?HnYne!&@n0XDCE*R)i$U%c22imhg>&2<-|1n^qTr2VBoL# z$WTiY2%40p`jqb#+fLvN*@eX+CeUNSGz)ZW^@V9W)4ZsD=wD(y!6$w%u^^Va#8R!F8mae2b3xW7$`%yLnI2{;IigdpO}QK1cFA?Z++*!aVp%3)1c7Y zdcQsQQDVIXwUwKbnJXf6W57G-mWnPMvRpBWsW!uV(TwFU}bRK}gO!5fA_w{tX^Vg7HveVs}}=O7Y=tjvR6&eU|9s}f5LVvG{>uQXzy;U7sUz1vzs-cDYr zG-18ca!;HU5awurR9JO~I2P5HkqyPM>BnpGC=y5Rh@{Os$0YdT|?Y`!O_=0%! zIo_X97w97k+QXtjAX!z{z{)Uszzw!ZgS~ND^F^Dj-Nl?Ys-k>L7D!exSY3EFh;My+dv33defq@F(l39Y*7NR$#hXUf!os3l$QyL?AY(Lz6^)NWrO|`r zSUXkDZg37qQ7u&pwrnBnG|LyXS~4Q; z7t7grbHEs}9+F8w)LI8L;uTAlMpUcLih(1b6sR5|F!wDnU640TyOVcNCBw5M(QF#| zAxPmD38p^V)i9;Clg*47vtW$Xj9Y^wb_k=e^UeV};=`80LE9G!#XZnF zi9vxobMfOfwoFWP>>)n)nYPUIkIDg5>C+MB*JO0GC`WXZZ7M+#TUK^59Ral_AOMD; zu{;sh3_B>j8OkPhu8kidG9tnu^l?qonp|lJ+?JU{O^b>UMuIi*(w>q6%qq+kFIU}$ zyh3#kMWy%{=>TIcw8ms{By}i0@8sPkV9j^t;Hf4rVHf9M0U>r)qKJFUA3P#+520{@ zM@iVdUNtlWM}_UJ?tE^R4_zZ0SZ`VhK*Nch1^VZUqeq83JKyN%Z}$mIj3mjHOK(7z z+r-jpqkFpS9P|%Z%50e0@Xc+D0djuIcrd(X!F>VH%E$R^^Ug$W8Z;<4+-7fYcziT^ zdOSHlJHPg^@eDTa=y+tH$G->T)X1Kp)#{yO*ft|47aI)k^UghUyjjNXXL_$>kx zdXreVSCslN^U?C#x?*eP^9;jOSxs#f7jBlQ<)$P-$?j8Tnc&POMYgk|PPGI_p!KGH91|m% z`Ny<#?3ek8c-@(!Sl5zqMYV?P5y)0-l_gJr>iwC1wUmYXE3Km%lY{ zLtTbg84^j|onutk23X8A49N%v8R`T|${W21(bb#-jq0VEGW`&7iYe5obJR%;Lgb1< zMeX5)Dh3q7Ls)5ID=?Bp$FkR;R&V4e!?N%n^Ek>H#sDg?O4DTg)t~=W3`l02je!Kk zUe96&19OJ!e)N=s6hO1NfE!w)7C;Q3AoD7|I8+)W*n^h2hn${K+_cnJ=A*?Eh?>Ae z_-M#+bC4F-wGUf~zdhO`TA^59HTv(=x1SvIuxA z4w6~a5)u|PA+G`mP;fH#5+MzRkw($l^fr)-Q}idIhQBegn5T21`PbAH1W~{U@0vV$ zRAf4GG4pCx6{2LnE63DE1DzO)rP2sGJVg7ts+AwdPo4~infpp{>3C)(2@8uEQ|kr* zdcK-|{pSDvlfQ+5iJ;-2Gwydr!_LwE-u~YBlcyi|d%NBK-p0n|^=j&f0+Rw-+>?{j z>zlc~RiAwFXt}!m=9|l_#rf`V5|b3=Cx=ZwQHToUlR*H0XMolv3Ac-3d5<`(vVB~3 zOK#=cLU&sZE8E$PmjI*l%h{_pufG5OhxaF)lR49nzbFLP*I!CtL}t#n!oSiJAIh;4 zflFr;GIj(vny;qyZ~navw=G(ixcyK;*R5E?3uV2Td~;MdO^lP@R;@uoy;E0IcOMX^2!Rx0F3)w#Z0jiCn}of*GKB7810O@{32f zRG>8Zm5V6E0a5}gNnGW7@a?!Be^#!H2?W!Sf?bZ?#oB74*>H6rFa$}S`~~Xz`cSp3 z%JnLoC|+QPZR{4bA{zkQZrOjkjIES_$jNKTy%U?J`H~nc({h_TCY%J$w9@Ks%Ywz_ z$!V|Oy}eyduRE7#o!9SUyn4H5qyE|U#&^H?y(ho;*%y1GiRD6z>*>-*s_*9Gk)=g5 z3iJ5M*sg>(?|hTWP_%E%u##brDpnYg3Smn71GsTJU+Xez4`9lw=ZA4etR-=Gy$^gbS{=EeGWjJ7cgRwl-CZ9fpC27fb~b-g(iE|7($L86ir}hFTN$NItN-xo zr#A$(aV=TG{&lkIYONEBM2;U{B?G=8LY#(XD*+!FzYi^Onu^VMvTF~INEzu!B|KP_ zF}ESJqRrt0Qf^dmjb!+(GaNK21cH7^&}>xbl7(B%y>rc!jMgq?qArX)-UrEQJg1pF zWh2x>Xc8YikbHYQIUws+cx2vRpKaj-#Um&7%%;-{)EPj+cc}f^kPa0m1J-W zReWR0`g#&@$Yh0>@2a#@rmDBNR(MzP#N z1KEO6PD%>CVn|rwmo-Ki5XrJ07Jx&OvAC^X+9!}AK#h~ATG8oL$QLkLhY7_)bpzoF zqi$(_a5(E)V%1g-0+%`?E|JFcZPLUmQzr&`k_1d3$*<6GVQtM2E~Y*rNL#J>rWNbP zRTi~xn?yw8h?h)FT#Cj4)aNr&7kP+|1@z5Ncwb zjL&m1HzkR0>J10hu4dtv)TgcqlY|e>#M}88<~2eu0z1-7MkkNpyq74_=7+E zH-GY{e{D=`I2zkf3~UKFbkNinX-Cd5E+aKWfio8a!~9ambWMBtJ9W$*tUonI1*toKH>|sl{r!c>MVB#p$Wm>)>#2 zrZL+#7QMS#EFK-~S-S6pSzAAx*vmAt$dn$_;Z7dY4@MyK!^j;u3@EuKX_}tgM2kv# zV`$^~X=2KlN$juU>q0aXHt${^+AmY)WwGr;iL9K5(L%W%FH5B!a-9h5O&4P*gl5 z16~;vx~)4ho7uV;HC9^-vbyQ^Y#0p#1PHhCfaL%V_o5`y%?kT+$9Reh+$n5rPGT8S zE>|u6O9>(nu2N=nw5&hO(9EtKA4Aa=@L?R4d-HiLAI#MZ;t)KB$7s#o2*@ zLIuc07A=T15gdV*C7cL2K~R}YSce{yX9#Hu$Mur1T&>20U~^y34o zZkE>AEQ?S_}_7(DQuZlC9ArRG9kbYr7G zJlN{*fA{^#U;OP~ot~~P7u*KRd99bvmuoc=n&#cUwPGCTEQpLAXp6y&BDK{FTOv;3 z|62FQ{T`deVR91g<#lJeF*QEOo6yMjwui4?zkB@ncsx1$`1#H0Km8ipB3zNbaNHE7 ztC$qo)TdbR-@UBaQ}0S1l0|SILAnpP8Oh6I-jM9P#>Hzpl57nzqv(L}tgeGK(N=Y} zpbYZ$-u@8V*TNGdYsF#-Q2NEVnH_6ZN2Ix>5|Kt?h)nRJdiYwiitGwI9XkmGK8D4F zhGyw3abR3!v1SY1*9II}#mEP=3pe>SkcVF1(N|1W$=bV40a{Jm$0{I-X0k zxOxS)|E+a1X{$1O3%CN9i5)Bhnp@RlehhOIgtBS#QgBd9!$Tf?bR1Xmg>4P?QUQCD z!{;CU-qmXH?!$-w`d@$b&38AOzA{Tvbr@Acf{kEM9C`>Sv-Gt!5tQiIc#<)fESuf@ z^}l?#f3WrF>0@r>`}ZGCKfI-_&z}A)_DV0xEYy`)(EaZF*PlFpL}v8|>=GD^c)Pngk%p~?0geI&_-7=PlSGsv4Cu8#j54j`SpB7&MfWP zvY5BC>A;mquY30H2S)qX|MZo`^8N8>vj1pn`vs>}8qRMD<|tNkZUDsmejLScJb!hI z4eQWY*ks~I*^9I!wYSJBX{}>O~U5$DBf(GB2J1ir9Gxpc3 zn@5L7rIreglxc@kRzVRP5J`_Qic65rF6KsCShJ`edm^Pd&DcWLx9nkuj5%kAbz0<` z9Y!_F&v5`mKf^3!wj8?{oH7O^h1ZeICa}+(l2TKkuP-}USno4+F!bGg9$63I;$o^7 zkk(r#d1L#-$(cn?lQ~@~q&Ya9taOFdkKUNM-Dp#O7!?tt+>^@V>t^ zT8pnLubK{@q2V|7(Aqew~UdKNS$10bSGTaMLtS;&lJD`;W zGD0GA8U&edQ`-VqkN`+7)MvAIG)_$Tu0{ypXiRgK{S8*uaChc|;gJv4{NgbuhznD$fJ!b}QZFQ}gweb0RMa=Nar%sr4q|hf6;&z|+@;rYe; zz2WHSXwS!K-o8Hhvp@aw|LH&c@@hG~>+rlFh6~Hdr2DUb|7X3OmoL6~{qAhFGuHL& zpHB@)`1H(aJw5|nz<>P5fBK8RoLL;df4Dh3 zJXo%7a&+qKoVKf^oEytEvb@O7tZe=SZ6rAV=a=O*SoXG7>_{-xEhY_F zhg<#b@?zNQUYuMkd>ZWTtMUHcN1uQG%o*0h zNemvdC`DXtf(9f#>iFR4#99T(+tZUWHdkg0j_D1XB~FdD$mEM1fid+LjiD-Uvxd~I zsGUm+WE5;#7njB@#>cf(vZ$gM*yWlcqScXnvnWcTYH20SwUw=l3A-;@!8lPUSSQPC zOIGk_vIXDhv$RMfU4a%**EU4CfeiJ-gcKM~_wO;CRT&NX>g9l zd%+>8*H>vkJQ_{nZ78P+-}+iHY4Ed5Bz8`B$*j!g#&@q?GPM~Yfw7}0=@3~9Ddn0QsKAXAG2{%BV zkRLn`p#uW0r8&&C3~m`&4MS=Z($@@j5egFtIWQ<5c#$Y?39cBvy<_^evlGyKV}@Co z66%o2e=C^N08p4*-4_Q^0wvMvYfwVWum;~5da@U3Oah1LTx~gN;P3wS*T4Cf4^m9V zo!)pap94F~SK};|<58j>>t@&QxWD=MaA+p}i%*{AbJLg_z%{g1fbH-JzkU7N*RMOL z?{BWBXM=3w>D(dYs$=+|H@K~j5oGlXNz~)C!|E5Ge>yvxzk7eSxLn#9jX_3d@yEj3 zOzm%X-kqNP^7lWTO|N_@aPGtJo!f)Gqs3L{SO4&jUw<{*-|OsjI~SL?)ARRmN9EzJ zGZHuVd|k|d>h{LtN5{pyc=ZB0gP3A=`#mD+Q;&4{?9AW!<)xKp20DHA(RFz-U2g4O zj)r@L-^{haVrCe#g94$4epg4p9|GPv}Ux#RAC7N(4!~)ClZPCLF~`RfH3-xd;&^y9e-g zJRFP$gT?ZqTGza3vr_D{lG1vBDxywyd0=aKQxQSU>=;=pnN3rZJh3yNm^(^E#td80 zJfjz}8=86mfj5V!q$>rX9ET#krC{ZDleZXmaOBL&&A2`(F!oAE%)sr=jwyx9%c(A` zu|#`ijVmX)q#%bN~`-Qb_?yT z0$Vz{#OGcv*phbSDRB{x-0e#5(J2&1z+97Dgpp8)Nmw=q4=5btMj2WP{%n8#RE?_x_zXD%8v|4L!*jxH7QS7~Nt3o)^!rk3uIOg{?;Hrd_ zD;NlCHxJjSLqcW&LWDB4xv3B$IKnLN126_V00X{z6N?=SbKTB#c1;j-N;c;+3plT4 zcV@azivuZ~zGb_9k?g_L#(sgnCY0hU^WxGMNzfk0L9PY&vAorsDF-@+Bb3m5YH8MO zHv+A(Ag~NG#)N!QPA_H#J8+Z3iPI>AL;6yrNgDZpPBP-frjr%iw$k$Q44SOQk&Imz zvP4PK4vdf{c9y~YF($;P+*DTNw&UPxow>B!J~rCq=G!-?%kxvGjt=@e81d%K8S6+e z<4M$Vm27%mEr(m3&mQmnt1q8VMt0^ZQ+esB?3zFrOl*8HGWEawZ@&k-o5l8rHy7Xj z^Y`C<{bGK3VPNLs!|7z`TZsLO*@XeQUN?^a^z8It^5o~g_v!rVtAGA}dU>gvr9K{?_Jjr}Oy8Suy-M(v9zb^>=@3w&(ov=G{3$X=|2~ z!DKQxP$hSx-`_FAXLZJh4avw%`3@`rwf{NPvm{(bvgctwB zL_{*M5olwpMKOu-H;H*Hm&Te|9Sj@=x##Fb3@4o03?hjondIwmWA{@C)88x=0aQ!< zl{%6Crh4njrBYqQ$(2iJOf9xVETu;ps0BwPl~Yqfm?C^P7AM5b2@>G1CX5kD=hRec z$5%DStZnQA07y}G&_pZDZ_&OO+1iViwXqzZXjgeAG!7=sI7Z6K2Z^#k$;1u|(I^`&~#k)ouc>%GJ{*tWP83_=m*p;NY zNv>F3?mEB}VlG-#1;n} z-@;Q@3v!nTG=R_bm8~n}EoRy}eFS6dHx~2#V5Nf58xUmgrQoX^(jNk$MfETkP|pE8l^F*v9Bs0vv%etWh2W&WZhas@XB zK-^x>WxhGb<&wP!L=o$zbznEm?<~JK2s^HDY8q^XpqLm|r(!I*?;7Xfq z&9&(@<)j^91zBNMJSI3sF&zqQ4I|u@V-rIFK$yDXctqMjf#!xhh!4ZvY;ti8Goq=w zgb0FpN?U*&ExNmD%jx#+P&_Bs1~5~PXQ#94>FVfka&d0c%pF(GQ234@nbvN0~{j1H+n^)gBsin8Ee=t6L{_p?r z`VW8c_T@LPUj577&@g$#2>rBqtTqL8&((SQbwUGePodhzwm>Y@u^x5<2#WyeCboNFYPd<8Po_#p%j)wHml9t)+^_dUc zUgrq0W6oCE^-u^&G-E~+vWC=dux*fT_Ruhc{zNuoD4ikj;9l_ybPwzw&omz5`}($s1T|e*kXo)1;P&cV`2lOuvOuDKG_iB zv*GEvDkO`O^jBJsqp^vmYG+;{H1~AbZ>3 zNiY?sK!6v#a@2TCI1?yfU_Db&s~8$*Ev-+r0yzi7paF2Y*N$h=zbO^4SjD=eUUzYM zKAA9RR)Af9`NjC({=0vB_~_`v`NjY9pZ{cel>=n;p6uoxZr$}AhIz)ygxg?>5ltj-&;JGd^Q|zo?ZNKemXN!Ln)_OGuzoa zJU)5*dN?^eI_m%Y^QVieuiw1$t%CW%cx-sJs|C;!+;v{PeE0KTeBo5lUU$qq7zZ%4 zPw9UC<0*98>E7L3t{xp8{L`D&@e>CNc4qUN zvo~M=5C7wT`1YIMa>?prkEEF{TTFs+WCNdKdPzbAh2TbK{llq9Y-B7vTZgoszZ+td zsYw((l9cy`c~VxS*Eo(VQj}ALX zq;n8x)6HBXLJnw&1oQVfsF{*9+!^1s38}f6tms>6t}kbK-(8qAu#Jvm0l-X;HWwt= zrl7}%6KEv^;PsDZNSj)2>J2rMQf z5fSs5x6%A)C&L5f=#-`~r8GaLG+;_FpvQDrM%gWMll4oTi(X;^mg?4~0a zRaRl$s3Ma)hhXbOrpN<~SAa=TTDh_}i3$5d%5^)E14Imi zq!8D3lVZZB7J(5d$74jyYV(=aF~b^K(f9^o2+C*qi(Evg71A=R#as6hP!vvK;gpdp zozQ9*?okM?!C13rgT6_X_b03W z>;L_GlkF3WsCPGBeRJ_2|L2c?|K}gQef`zY0PC(H4xKdiYj-r<9}f2QTHemzyE^C~ zV=8*mtXFmlUpb8K=2mxKFD5NQy1VI-8p$y%kR)~%ELWYk@6W&a`qit~H{$`>@cr$r zMLw|8`Pt8&efi}Vx3{y6n{!JuKps}=1H^t>jg`~F5HO|IbcliIoJQwVQ)?p-P#?;` zoezg`%7S--V)V>dGxG*>y$s!4H7A6k*dNdC1U^X4=Z3}VXGWn_r}H!_piunevJ*gVCq zXigF=+9PEd>@spvdDoXfUTuR8P#WXfT7_TYwe>Vz41%W>qJvR1Z`|e@HA!xo5-=0Z z0})%UPo8;IHva5M}crYHj_Bun9G)5^9&Uip>rZZS_c zJo0-VR&bsyD zzQ>l$<`>Z@NN_A7@UV~9!;3T@4Ru2jeEY%E$>Ff~i!YXc^Vi=R1?}{1tQR-p=j4Ht zlZ$>|A6W;(Bxi(J7X{I%7Mds-GxU+73ao&kfZos{FZ&&l3t^Tt|gPXT}o=uLA4>qIizGQ%HuOcv%h^~<>v(?$`*xOGTArNF+~NL#QdyqZ z)-3xWQ`TGH33(%{v4~NN$Qx^h3>@2Bb8gh#01i`7Vf-V6H!!U zjU86#eRPpc9T}*ugO@T)nQgcLVsY|WI_s#joBKAaE(r8<`-Rx8L zb~??^5{b;d+}xRPayWIdFRnp+eIE|cJIP8@bT)WM9Y}m6yNFM9>`GGV8n56Efz8gB zUw*l}z1+OB`)jTq_K^{{o&U=(KLY=Y7jI8b-a2*U!-uz{{@wGZ`?c!Ep0cG;Yr9N# zyS!o+b@#)`Dbv;69r3-gV_}hREM`dzI9jiwjq8UKG5&~L9TZxrS{Mm zzMIai2IYm_OlK$4%Yk(T#5VpxOo662hKbZDWWHSHtKH5cus#oh_9d@)&JJnRL4H;((vXRFZSYG8;U4lakh`qS?c!?D-`d$f7~I?)-roJNwc~)K1x#cn z2^`6V!+f0a*xUa2`O{zh{r97h>eA$;5Nm^Nh~i>pq!*StaN?Lj@M1@FFjgdK*EbQ< zgd|u8!h|=auz`%+vRf&UG#U8Gc->+#Gyc@Myq;R|=2Vld&eJD{pL~3Ld}KjeKHSpl zXOWdPt?g^X3Mjh`KkQJM9|{?1i=383HdAYHBxOZPV{Mt3D2bF}K08BQIck6fyxPq0 zmUU`k26$*BPArQa7cmA96&iBz@RoTF%LUXa8Wk~;&_5wV9F-|`Bwf9UOB*mLxuHnn zo$6{LDoOr@*b5JWJr5A)4+NxMHx44p5sVJp-tBQ3kp>(Lx9;=XCS~9QN+ZLb9 zh2#BWl#mqMr%%Zcie0HCbP}6DOI%jj(v4ix;j}Ke4$Z?;oQWWCziPbI(i5T+*Gn1l z^IpJWB>;~&Jty^Xa!VIt-**{5d6|rw*eD5!NXZ#lsEIRtQWgWAIZDPeZz7N-9wmPu zt1$y4iX*4L!dJXmom`KvyT&_=ygO*h7Ob>k8Kq&;3@DjhV#t)1**b*PJ)oKv45+B+ z2?%Tgzs`nHj=LL2`7N!&iRTUpl;~`H^wH$#cyh3}{q2ia(^D=)ri{|d;2Cg)qZ0IG z>r&+`*PSJgtO*5)A>zfBl9}AKpeGh1*rekZ-xaXn(H^NN-5vWKOo?6@9WeR2@o|=j zIpQFPE*eI-xa_?8;d@()j~_d-*0@uwmwp(0!@V@m#*}qRkSM~#;#f~^=hpu4k!}M_ z+1`cGQ(G}?ALto8bYwm?jbn}6S&gr(+qo45U0$MnXGWuKk+|6yj{ueFoSTcY zAXF{ljzM4NqC9b28S0&gF_f5@3 zwirmY1e#hN02pDVX+ijMul7YF_djrl&q3 z(rU>eFf8zDWcSdm~iIqR2A0$z*w9 z7iS~0>1ar=Es0p^*%+oEW8{qIzb44~r>aY>A)YcN%s(s#KrmooQQqssM-iJ<&FlR6 zx3(JV0*RukqaIo(f*1cS@`>XLNgh`)A}#^l+B`kG=nuM3vaobK*=LAN0g;Zg(;Vgv za~YYcMjFWLP|z$^*bszkiY)+C;)YpA7okOBt-X%z6`x1Ed+*$(Z&*P}#kzVc!hg9E z^d-u8)GOl~8H$yc@cIoODp$oPdrXh8Zhmr~1?S9y5MLqLA(AnuHK~&PA%vSDX8XI3 zhDZo31b8LYU7pj}Xjc={OuQ~;1U?6{60-Ixyo>;~CL?!#NM}>6!!q^=1`J>rvoqtQ!|D00zvm`yRJ#dr8S?P$4%| zOZwGG|ci`8*ySC^spIMANMuKE7Cy|)##LvxiGjW23 zz4%w+wC?4~6^qzpgb!z#JYPdvUK~GrzO(UsGBk$D%yld&JwMyOoSyKK27_U5$fLsy zU#>LN+kO1($>sEXdU39kIzn_CdwiB)Y37sj97zTwa(H0#SqBg08b&&jphlrk>FRp= z=-7saT{iu4Hgg(-Whp18XTuSJ+H$Ob3{4<&3Ry;4eeIkjUGhxrAlmBre7DAA>(mm1 z1T!H7h{b858`dZGK1fX%httW?<6-ad(Ky>h=L>q{yJe2FGD7@N zLx2K{iOj&12G~1o#=6R%7+J$1_d*yW>RuYUt~&q@cR-C~3h@mxXjvv(bQCXqPNBR^ z$>_3iQFb5`#W9M`iEv1AS{#(lxg2XV-{7cg!PL}QfhnpiS3LfFT@wf)B`ch@j}dca ztwWKuAdag`{#_J+ZLSo6W5vK|d5pU?gDA#ZF(g*Zo)s&lf$qtleJ`-_c0AgHZ_IkA zmfDn)n0VF+AxD|HcCXBqpkDr%;K^XX^0IFF*VGlc-0PL3;fy;ZrT>7Bwga49NDG^R zT92wOUo@sO1|=z~XPWdSoqQ~jN=fP;)7Kacs`H0xIjC3eDdO5#7w;Y}-adf+P*jRj zVEbABq6QQ|w)U*ci;|+*SqN!IU1v{Ad2qFg7K_p3-JY{=HujE2 zkDu-lb#0u{d)Z|PKMDotQS`ZaJX2}Dd_CwIrm~Wc7kqNE{Pz2+iMEZ@DonmZ*^JkD4l!4U$vS?-Cf_{?+*N2-(dm+`X4{72s zL5%a^MG%i(R7+vv(VWe-{i)hii#>+JekLC57jPc`&6RjLu#8Q)3?Nf*oK9vK+MKpsYW zt7>8G?*s&0gfa@b{V^1cQnH)1jZglxQ8=w_NIOCL)uZvj+-lJ%vkEHjn#yVBYHTY- zX&aHx7L+Ph$HSQ#fz^$g-TSnh4%Bad`+~L2ZY3(15WxlXhxey}T^HzHV<`j8LC#j7 zqNJ|3ii?vN-qWklCCCTy&Aa^rqc~REwJwHbK(e!RSB}5KIT(q_PU38HQpnXJ0H;G# z0-u6aBxEeOth|v-k1%DOcw6ykY6LVL+0Y?GXx8Mxt*)(p1!|EoAQpSt6$#x`ws5Q{ zDoH{rT0h6UDmTa%XAKidkOntZwyN`TIZcbrG#S~GIs{DlhC$ktMcR_^HaSummnP8O z`>^faXuj&KZ0=h$w6n>O*)hgUS{>l96T=2E;OwMC@kT-o^JDr5CM6<%72?-Y>KaT9 zGWQ{NQoS9cvD+2`@JF-d_xfgfIk#lOfhHJT!bo-W935U43ki|55mm7p>c!!R9NuKC z%|1h1c9ss5R35vNgSKr??cQ4&WJljvJ1~ zXq&w@P4cxUd|1P}!{NxFuIU_|wtQrv%!B0Vqh@?II&30?xW#ks4NgukzIpNTs~6Y4 z!O$p&3OprqhtAuQBIbhVJgr=YyP-uhHGv zwHbi2U)|U|rBz^9&6k(*gC{d&oIjPtygDJY>Wq>I0ifM*FF5r&O94BP?UiOGlWDO$ zdftUn!Iyp32_?-1Y-NhGoNjno2f754bW@$>Vi#Z(BgI-*wQQbCu2uo335cLzEhe{_ zd)6u49+G07m?t4){g3f>f4{t~$QH0dv%31trhu5OY$QXV$U4JX_nLRfz$b_-t9^HH za0swgLcZ*XoyMPh39owOWKQUWs=Vqas%cgx7E1+3chc zV+$VJS8WV6bh}9-W0}GXq7_KDX6GbRj^GBENoEJY>c-C1N}R?p^!jmRk#5jV^nducx(Yk?dCo*19sK_W!ea&K7H-lyFzR|c-6PTs8p z2MO^)i}1=96epxOA%#c5Dt?V%jB17AW}p?8a|=ryQn&p7SiAElxsu~f@L6|dW}PS$ zj&AhH7DY;;IGmAoW_HHbY|ZvhU)x`HM=MIRRErdwec);Ug}O7d?)`i{UM31?G)0*y z8vW{Jyf{4EJv=--JR(SnEG+lq-l&fpA5T^XB6Xgq2w4kC12W>LH8rS2Bh6TolK7c9 z{K^&*4Xi2eyxk;Bc7Xv$w>8qB!YmVxrFCjwpa0M0*?~hyw(f2|fBN|EfBip>PkZhF z!SSwaUs=-`RGYcyvzNWck6-=fH?My3`QVp-@x{Y?&aOK;*nhFL!6z#MS70cRIJm%0 z#BOze$S{C`5a#&bVrh$5Mv&G1aBXdfJzQ=;DWJCji{V)=tqKY&^!o$R=6}bhlc&#K ze)rwwk@ISodJi9M-Fx&?=L|U4_Uk`9{`T<`o@~@zhEtm_cJ8cm$2vav{@~SU@36<1 z*}MAcr+@bOy}>%`0#6$vg&P?@#NB)p^l`I0Mq-{BeJD1KhpplksP~GO1p7>jVdM2RvoWE zNOagb-vu!+ET&PXx}L}U$t0T&TAIL}PM`cNk*yQ8Px(@1T`}Mr1Byx|mV98Pb!JO9 zBQ#7ce6qW=xi+}Bvwi>W&ix%%wQmg3d0}qd{_2&*^YQ8VgmxttS;?njnRRc#??#Ob z=zV4~-V}~Kotv}bM{=QGdM0Qz622A|Pq~8^NZ||IQ)ZeZ17>P$N=~&jZL-nX9%`vV z4gZM>D)aw>CTOoOZCqjS6yKpsYy~mMhckk=pss0p8T{bYCxSu;6mJs_!K$dEqZ8?9 zGGWAHEgc!I9%`_fBnz@@85lQ;#P07 zwyWMxe)f==|7djCA8fsNJ%M3Y%@tqu#-oeVW4nEBc*R?H;E+r4#&JxEjf|y9XKRaI z0IQ^`X*hRaaLIGaAn(lpUKR}LKr4Ym?GZ<*h8UqJW6=J~>199es*S)X5-ZLQH z-R=GIS6}@6m;VcQe0L@M-9P-p|NPBY*ut%Sd;5nl_;T;{-~P?7c6YWOKHT~ElkKa^ z7fxL`I_MoAyNvc`=gwVr56(PnKUO|mNXm!8FwJg2#Ue0G2T(^>Ko=%aeiRQs_~_dw z&+MTG1tzTSiB$n>mZWd$J8px{Ym9AC*Xvnyp%9@|wTEV&f!n(n-OXPm22qPk&Oc+G zS+)0g;bvoVxN~Rw`1C|C!X2jUh-}dE^PCc^o8?Sfi=q*vMr3m&9LfTRvvd;ukyXgS zZQN3PHTt)I6ad;`bD)Q5%R26Ysi##6-PydH+7o{X+-`0{2T4=6X5W&h0)S_cvlnA* zq;v^iPl;#Ayv*{p^zCcatEk8~oxMf`Inz1D<1_vboD`)e7l5h|gqJZgskFIR=i+sg zk(PC?$_%F=il?6t-zt(_F7Nu6cP9*aZz1WY!FeBE@ht%8t5`YEVFK>-zeX3R^g8w>#P5})?G;o3ZFs=PFJz3M2-2!AddG=!Cv zwT+F#NWJmKz|J}+u4jK>csB!Z3^&X;N>-yNwXupAR88d{fA{p`54RV3XY1>m&!0c% z%ChIE>Z2pd+Qa)Fj7QdDSN`S6(|`P*d)zDTZ4X!1Hm}UU*EX+T+n+kOxVp7-&p|SD zYxlAo+Dq)>1Ea;l!nX#mJDaO$3GG@2z2?tO7bX|lpp2xNEgll|VDA8nk{``CZ6}^W z1N=Z1B-MKYtAci6Bt!Q7`-2tN;jXOiJa}M(?QnhYcfbDsEY}$Wd0mr#>sxo|1_ae_ zfA_mz{`D6dgSFAg_?Q3jFYoU@`~8=%oe}fw>E8NqxO;bPINZEEKOT>{Kt~fKI7=}D zp!eVqggNLAk{1>U+^x-()0dnyj6j@teY2-8G>vgi12d1&z%Wk6OU(pyT9b9a(BIe< zvWW1DkVSZSjqL_x@5?a+IA!kl9FgR}t3q=$eC`&C)G3toxhyl~@Wfan1k z-!kcmk=Z{K;YEP;BJ!gLg{hS`=YkBkZfAlf4cMrFk&zTEcTWlt$|?b%N4kJ>w>3}4 z%2x$cv#{{_SvpshOU;z4N{?-gIF20iT(n&2tRa#Tv9YF(;e+0u0nPV|bG%cNP&qJC zOz(Upebcr~8OJ2`nFduq3PiU;s;>Y_pKgCm1O8}8!*1o0cUIav`C7_%B5(g}^>(!v zc4X;tHDnczh{#C2Y0l8mKLl7}-^%$Wmff{EMwwVc!44r%$Dn8nKYf70vbzm*H;ahGECnN4c z2XqWJq2;;B$B*v*AOHLR`rS8QKK{dRpM7r|$n&+eM>JVpw`3^2TN7zR=c5T+ZEZLa z!BylWK~dwD@v)9HLA%Q z+iL?mY+8f7_8I=u+Av#0ts4BNE{sa*XqeR|XK=tBzD4{y+jj{h{t~dovlw&VxcG{- z4OeAm9E{17&Sc`|EX3mpO;2QX?aW8hrI?>Mf2k~XCA=m>^leXvxQij#JjJwka5G8- z`plPSgh;MV4rHw&Lqh17u&`cd9i&8~d>K`nQN);yE6bjKvpAZ#Yu-noWSNYwG*Y)w z$3%c{l(A=ENec3)^z?b1pHoe|DQR9&W=pyQEGYD+3(=lRz)U0l#J})`fUSiMk1~3x z`1h5eu~qFX%li^dMh7X{_^K`(5<2x3>@R9kt>v0Mr&N^tP4LwUoM&r- zE*D0M&Hs9p35`JIyUFpTOu82)p%y-hD6NQwLnRBXqFJlGr0|9l@0b+xMSl=UHaIs~ z@yiZ$AD_7*8y8q-vN$qFX`R?4X3f&*jdr};$74?9dI|)Pu%sW20#qz+fOY&)ro8wz z7LtU_&oUFtMe#A6Mj>}1Zf0Vci7v;&H9yWeoqN02x9_;Al*1k~trHNloUUh;;|e8C znafO!G*4^ex-eCNtTi1ppP%b}|NV>4Ke;zPJ?XEm9~~Y)xVO8vx91eVPd@(WpMLYt zFJ1srK&`(V4cwNvbMJ5e%l~%hSi=6o-rm636^T18b^#%m!;i*C@ML(P7)fhY9s$ zVzbX^o{z}_6U~VU!DpX5TD)2K?lIHBc-VjT@yGX;m-=R7#A&=^j8sS;5d#pdfxKL$ zh3jf_!{xoZo2x6|TS+5@Guy`*CGUWaG1G_)(P>H(22G>zj)&6Rrps54m62%DoQQIr zM~$V0rl@RRns>srMVxr&SrZ~oiQ9SSR-a%B46HCX3?tQ|bR>0$!|GYlGm>UynR!}^ zG^@4;kV|GscCD!*_2aT|xo3?P_Q6@mO+=n06Ug_>MpxSew0d8oQawAdkXBo@s4QuI z7#S|pg>sAn!+u$taU_7VHiuC#ga)x(OLT#6b4q)t$jS}SyQ$reseeEX`R_C7v1tQX(_Fib`$sY|gWzLnP{8_DsmeuIk40@^@8 zm9HkGR%wA)mXwg5)B(_b(m9~MX_KkD2Bq+~H6=X3_1YeFtAgnvWp?kog8Rf2jH*Mq zDQ$t(Y_hqzZ49tvYH=xJvx7M0%n@rmAaG0opk4?@dzOz5lH0&&F^lht<~SH7KCOm! zN2RQ7ZR<2gd(UhT1XG-L*|K1hs>7(u@Zh+j7z`{Wm~Gf6?5e{vThCwb9UUATuQ7!! zn1@>SiB$jS(PxhzKOYPoZ*}z1N4x*&Z~yD@c(lGfI6T?^>gzAR`Suw}vbnxCvXHju z3Z{keu{&aq|K;~zefG({yZ5(;{Z9`!tgSiS>lD_t7xFj&u%8-?EVzwE<7~PqQwHFW z8p7l9g>>OC3>9;M7io}$VqPIc=|c1s0XH~iXvtwEG*kSgfw?QKzxwN6|9)$$=LlLL z#tOZ}E6Z)(Ifc#=uVbQq@`(#_c5bdt8Hcy;ZVi_mVRP}-SNl&MA8&5-A3WHu(~Dwo z2L?HtAvoG5*sfyg$jKYTj_)r?7VnqE1>+L&;FC&II1tnn_PTb2L*iYF`F9#+k^?t-B93ys0vn9RGkAUM(i- z3d(PVrvnAM*DJBxrjuEN%*YTeD>DGrQlO=gsRV|-X69vKH-&_boz@j8FKEA*idU2O z7OJCnmFiw1I{>0&z)Zp-*Od6u96#+^G(bEA~m5;iuTDkZ~k zz>+ef*;FyA@~bD;Th#1-YaoCE+*Iy($)x(nzcVA~RNMAy><+4*KxBe+G)rBJyj8r`Q#HFHJGx{W{YLd9> zoryvz--M|AQ7SzdT2*bFm$3~TUdX(xVLN+_hik_65Z<%Bv?@H?i%;E)D)sh8g#yC| z$4V6}8Ekn1D*>gj&WiaGEn=Yg3rq@nXn(+0Z~2mpDZQRDxw5iggAODT`+cVdT1mj* znjD_&T1rO~6ZbcQl||JwwRp7jw}lmlss<(#u*LphO}{&Q{am*?yUCD}lmt9vk~@&fFAGEg~i8@pZ>)!e!9K= z@Wsn#>)YE$C$C(k_5G{wbi4kr(V0`M^FJ5tJlGg6IYs63g@^k`hu3GvLzlJOTYvKS z52VmiHXB&yWFUc$91F@cp^4z3?CahU3Utisnk+?eegb`val5NT+tSI3GToyVlt>(> z;9GH>=h3_5kfluU7GAvQefs$d?*tw%G$WgSBMK}R+jY9U@a?zX{>@*1+Fx28o$Nax zird!a*5LkwJKsOwcX+>z6|SblGSuE|jtYmsWFtzGv^FfE7mD-q!Q$84ZTAjeWHhsA z8BI1^Lu<_F5F4y#2pb(ir_nz8M|x3OnRtH*qe$jdBNyQ(ag!+UK!9>(LmD|vp4gxn z)!pY|Wfuxv^%T|5fds|L@F}Q*2`joo(8;4CP+>oUmbn?_y*EzN%h>sX?f@G59vN8g zp@SAMoQki+e)^{^zUVa#*dhl%6(uHcluEl>cq92_OR4GGJ}piPPn`Ey1TboLKQbGq z9@=Zx7BUyTY6k)nH??Fz;?$^t9O)?~5Q{Evu|-#N{z%gxp2dZVKN5VB_;!NUj9X)S z%UPO#p*WIDT)`tJ221yymY;T3nR#e=QoJovZzY~)lC}h*){ANhT9Nf0`*nR+mfqdL zCQE;Hm@4Rb#V&a-B@t-lvuNTdkYI<5S7&YM@ks@P-*Q zgfi8RI7w0ACygj$6ag-`e?e^i&_4>RDZ4AYp{SZ07>Yd_Xi4Hk0e#zB=HBc`*~pj$ z(w(gh=Sy2h(juFo-6-ijUKP2n*}V^IX7--e>*N|wE??{)XIe__#DXPj^g5jzMiefK zL0mlUZ+t9LdlHqifR^*lHGO4mduR9b;02M&N#E8cnKIY3U4pt$Y$zkJA;Hz0OY>)Z zqh)>ldc41XXh7ub$)e$OHw$+b>mEIN6t{$hoAvGf%U4gGP5Ai5cVB-KhYiBup>#`_KRJmw&EfFRiX$pL3^u!4ho}uri-{uIU{N5UGy)IyyKe0)TH6 z1WZGydd+mvB2poWAH)V9C2c5X!T+rg0`Uo9si|`c^w^v6$%wW4;(B%TYHzUGJ0G7@ z389EN*t7s(kZZx<`s&Oj4i07?9lti(v7OBIMo{ZA+K)dPavED3gl&pNS;k04xXW^q zr}?#^$;XhJ&FA8Z(~NnEa|3?&hZk9;()}Ys^9aGh=MlHy59&Q?9Yt&MD{(b@_R@rJ zwG+7X1WcMGOoQY2@CRd-8X{uzN4o^c=oZ z?RQ38@TBZY?^#TinBXvN5LKvRkc>6H+P-mVYONQL^-qywj74+1&du3x~ zU@IglNq?~{g%dW{hSv*O3j#?rYk008Z3g!WRZ&nU)Uq4m#!BTGrMsSLj|te44h<&D z+%e0MXr^vdMUyWMX|#{c4cnmm#Foohr6+nKaBG8~YLA`A$iR{bn$R$<2EcOb^cSKo z;y9U+K?gBKgs|8G>>{VlLI5d1SQ59}_a53~dhE8OYlqX(ccNljHrQBOx6c&kQ#moH zJcAm~D*WWYNie!t`h^F7$+S>ST|7*&j8@_UCbZ^`r%zyU9hhQk{ zgf6D?Xj3SWQ9C`k)}$hUv+SS#ka@`h*T${2`VFONEkc{2cx6vH;zV%e$;xY);sdD`fpOTJn6><+U7N!tc0p@Kh}_3S}7t3jT6Jk;=~0m&qyYK^Nh(=i&ygla)8HcQiqk5nOZ)bEmXQ&T`e zib`fs!BlEc2$u7&W{%zyndsD8FC~7ATyaKByfVWjg_SHAR%kz~q?8vqr^g00@@QqZ za);a4`;7gW*T-!(g}IHXcaD2Zm@55rd{I!2=AB>I+;V&!-|U=SqGji2BU#xli8FKO z2*jubNamzG)7ZG)firM4%h0kt=&O{e%%ra@fa}(}!(GK&ITql9k_37DHQysf-potC(<;=qLqIBAJYx z@5BgdPnTI?Cb?vwPyW-FfJ)|?3!hw!tO?cTmg2o9kDo`Oaz`=e-{s=u<1qLyE)6Fb_Kq4(NJ0ZSXMA6Yy+ z32VMQIKFEyfG&M?eR6baXm{FZ?t|HI4EOo-Coey^zx(MY_x|(Wd@vcmbpH7D6}38N zYk|#*<+;_BjA~IXT^Pat{rYODzj8N6Pr8Km%ry$J`#b&{6$U%oqsb{e{{EZqe)Wsb z#>Y<~-Kha9{Xtww<%~SYF5vJf*}rJF7+sz{Wo}d^6lfVAjR}iz7~vXz$R1Z%O^zB= z4JzVcMlVYu?-a?wFmhr(i=`yEgk|&3wz|Pu?~I?&=wz_w`i9ZjjpL~nzCSqp$)kFL)OM{z`+k2Mt|5$}2y5fUh_au84OeMO!c+wvOYd_1%;BRkA;)NU3$d-frI_+jtkPwu<=lZY@mJ~|vdbtA*` z)A3ltF0C=HSW%4Da%RzXxYl=dnHw$RU)hVHAwASI9=5f)=vRU@ zkL3YGW#ou%k5K8A*oXH*HZN4-GC!1F42m@um?B+SDjZ8$1_-}77-t9#XM+Ot>4eey zRN95)sV-TcJkn7~SWocPc;!73DKm!(x?ZWVij9O70^sy(OQu26k{Ob!&))#87v|lM zJTn@-FL3B-r8N+}H>AVe6zcp922-_iV+zxHMUp1bM1w3YN;Z&Oxj!29mK-OV1#2~u z4swxJKmZ0%be|xXj+#ZRFf94(Y-}1W`^?qadd^;x`23vDd3LoUMhU5)ocQqGdJRdd z7H+5U_E^n+!TK7Nhlw#mGy_`(T0DZk6CbCJQUh*bLS4amJ_k=}VTEAAC)h~CvzK3@ zjH4Mw^_VjGWki~mrHKk!p`Ghov=Ok&lG3siM#P5K09E8I+!hiuhy+eQBK+y{b~K$| zj*j7z_{dBdLXy*vp}Zo#N^6Q1M3n|XxN`fApr3}KnwbRon&Y-8vvsS2LXd$g4|CXH z>+Xl2nOnVn^7Y~I$->QKXLF6lPq7}I99w)_q+-&p-KTQpbX4*f{m%_pMr|ap8vwuv z3IyXwI4?K(LKNCEtLohbAcxxCj573InZ#;3!?)s0Z6m8f$P8NX$zk%)q=sSiOGC{+ zEQ|MyF*$Ic2m}8?1um)p)nTX5uXd;Mc{l@bLW^1zB%w3Iim&%nE^*Hh8kB9-Yl&29z9t3^wWDgJ6uAHO=qL=K3?cA z4t74=`22(2z5UT2zIy!i}_n89ozx21DvZ%F}`(gBxM5gPi**_sD z53z3gYb8&n;xBbqV6j1<7D_j7{izr`bW1TNXKh3U2vEZ0oRGUbRY|InP_6i~b-MKA z0wF*Y2PDy(kjVPvg;~&hI`cHNFV*Xmny-OKnHZ&B+M^Yc_^K-$P+?k3YYZxc=h`RF z+1HlK&sLFVlIcurg*9KtRhh9wlQa6*;FufknAgo<^f0newWaDfv^2V>!sC2_RFtZD z+xt#%#_06O910M0H^DtLH@z({s_Ot~JxI~zS4+n!AzOt-2$D5P>;;$5bkbvm<=kbY zY1~8ALdQ}XX(gq8E%6USgYcbrkpPz$@-zq73W?mFBJ-W}egadNu=;>}P>csKs>&ugavHXD7fZT@=<7`AnybL&*TMxe^>EJ_68Ge)y z#T{@N&pOTlEYfiVUv)48k4f7hR)?b_rjKs-SWK%qybexsX)Dq}1(&MGgE*3;oMM>S z_?n?^GDa-i;UeX|;*s=_6@&`_(XoWAHBJ!S2Qfdd_lH{$x0uCt@@YNwLk1zei zAx@>0BO(UD6UK_swZQj(I9sXBY(QZ3Rl z3$V)zuFDEzN{P0fO->fm#crz{2!svome-MHUb@y6Hd=FN)*$Pc2-A=nbp^l0XRu-1#R=ICCl5}AvOA1T}YZFF4Pnj zGIWScf=1nG-d1M&b^BApqvtHFudgV|&sIoF>?e&wynrEEfI%b51OD)AI(Eh8_70N5 zYHWm;nXd$#plp1Hdq~$%F?C#Q%Ct3ZZ zn8FqHu;DGh%K}C-jGBrCInk(vDd`Jfq-^}qzDNNSI<7>9I18{*wxHJFWVazA4i=YO zGI+JI`Y-=ehvn8QH3+$kvioi2vENf%829~MTJ-bBF*Pm4()rpvfVuUr91S3P4SVl^JH6YnRQBl-bvoe zLR-?X5TX!*at+mmoDeITOhqQX2%E&kOQ?YTgc)~v-?{T(I9xhExG>+pSg6g!RNC|S z$+AdXotdqFh%|FVqV*R$iDKt2Zm3!^VM3=LnI`pfn8-Y1{j`Si0#_C-&|`;Jnx=-_w;`FKZ}5ZEt%c=lpxW$50s<-upK{_o$7 z9d5b6Pi}d8V`Y6U=YdQnwHYVUOT|Wn%(h8kx0P)UhCyt(VQB}sZMZk!Rhy6xm^tXK z^p?zkB*;vwFo`lk#<$iJqlW7lnTs&;A~9k(^@tT-(=x)vcqVN~Wn}@s)ci8^=)SsZ za}p&W3|eGTG)R24Z3dR%##a|9MkOiih5SOag@o>By%&smdso8N*ZNRhCE!>86cu7j z-;rDqZ48-}C5?Fbs;kXPdd~~Cn3X(xY3XPvp??`S2neFeQ+gw3QK;jL)<91OySX$a z>DU=E6UFvPv%R^&Uj%}fW%XXCb7}!a>FVq>)1nH&ZFNoQjpd`!py2OLy@6fQ(z$$k zGyqTz>1?uY0MnC$(rSEEc2tQwN3L2)hYR5olux^eqyty2a$sukC5a%x6s7rjD*&lw zzZFL|M2-+H?<3nS`i2J^Su!1t7^y+gBr2HCGFO(clqOzGB0-{T-98$>;A8Y;V!+S= z4`#NpQ4X$#{-CFDDUqtNbEI=IOb4tgsuJ$n&=pddIw~Q?WK+89 zE2~`u?&)N_Gg#hQ+y2geaGJ9nh(ry?t`rv~&7N9&C=8(f*_lZHNH4#kSt<_L;u+RAom@%J!bvJ2ZCe+}7GA~!Lsu5z8gc@Kv6Bq-G=L#)5h3ah zFZN6VEkQWN)OzrAY99RA*AO8_E{0Eo!8!L8MIMB0d3`wQ8m_gwFpv?w*d1lzB|ZVL z!$n-z@IQXA8!aiasRV=(+s3W=IbK`Qu;8u^Me7E}hQy$_2&}(&7Yef>c}mjXiVR-b z*Onmfp-4Jw3$p}TF^fr;PsTjM>B&^j)cIDZWQnO#^H95=;uV;Rronk7w}mX33tv+{ z@He+AOw&eo^w$VM7AF1795mP_xBSHiU0rJE_|L4<;6_T+IG#VzI-3h5lb_Q#kz6n} zYuq^;o^5XKp2L3yRZ~KZ`f@py8TMO4a!4E2rlnqAJCis|q3KD{&eW};R5Wu;@1#Ho z6H+2Ybh4>!$Ul{+gRMPfoJ&pZuwAW=%Yj zH8mnV(`113yNN|BLczD|86Q5_dGh43^VVF>Ms!__FDO$^=AWGQ?tCz}vDS}d(ae}7 znC5d}JZnK)%OFULtymjY_x4Uv0ne6MK-1E|B4ftZbgPcfRUE3dkRKKNge*D~nUKWL zR2=Vx@!!7g5AW}ooVs3hZi(bKop&T{ZV3b4?(W9=T0aox*tj!7Hl?NWjB;wptJ zR#icF)g?wUzak=P-od_!3)<*V`gYPA`6IOyM@jEpx=j1-mU9(0uP+bcTXK0}|6wKt zbdAZm!(ef}>kJIq217#Z^5Vo?a(jJsJUU%nVf;33a$X=GmyPs5$PL;#GcYFGYIycv z(0)?m4?;ZXW(`S)Zc9yGas+lxrAlCSU}~bSM8}0lg#Ro zU#|$`#dM|f<{z{TR$`pV3Z)+gB*m4O3kq>$_=<4}Q-u5V|BPRS*)Vi4oF9ejR+V4j z%`T>qp*@qZ*fv>ig?z2}DytYT!$5u^Y@SVURGR8WZ!*i<%A8gnjI_x1Y)KvVp;dyP zxLjztFn7wI;=Lt?rQ^||wo45(fRHE6YU~|~T@4tmWpRaR#t`HHn9HkCdB=xHD)G!> z9RP5wW>A#g8h}AHxB*!ENb`bemFheYcFkfVU5M|=iaLX#y+|OSCyrB4WUHQ_dqe^<1d=aTA&qNYpz-(^XZ`HYzgW5$J^4m> z!Q~-BFn&k#M`w;zHGRo_e?p-ppanX9iY09I04hWlISI39*3Kw+@a31T;nLUBFP(Ke zTpifNOvXRlb!y$Fs|K&nkK@$HpxOFd9Nja?#VT3QVIW@O#CDp&vPIW75AvSC#48Om zKoTLoF-eDk`UB8zsyCG_8PWYq-y-17KKtz9vzJf4eRAaLax4cAOAAhEWRL89`tg0v zP!hXo!Ht3}q|w$HTZ-3kn;QI#Q%76)s$N-OM2Pw?1X6DVN;W`aL_4JI;kNh9jEC!@ zaoGPP``yjKryuR?zkcBc-duxdDv2RX=B;9xGkA! zQ>21JN=ZbMqjM)YwU$oGbwOylyqX!8d)NI84zQJ>F#(f!V!+~;U>O5f6VMmMAs^UT z`Opn2M-KM(QmT$-DoB#3qFDo3S&iHwHnCZfr5mUGSe!r?p2=re(HnX2@-C5eI+;@>H) zWqcxVC8DiD!Denq@k~I@mdvwZyq-EKtsochb<&^l4$KPr92*vy+v zX%k-12ycGNtz$m!N9rk6CDQiUlc)3-)rn;IQzNe$RV^$J<-jPZRr6;Tz~tlyM>Azn zHpa!MA&5P1zauV!4axRo)>#8Qu=H#hzG{*9ZT50g1pyEW4n`wRi;zR0gHtI_cA?&{n%?T{m_Rg@uqU zrKL_aFPLaZCd?wqB4f4Hz|>HPf6u>_N`U0Ub1DkzF4uNjWzwU`i<2(CzvCexxDmUvC|>24f4 zZqDxRY^)8JoFLE=)g>T90f!TXFgt#U`ZGCS6$1 zfrn-Sx{f`ecr(PwaL*5vD`f7Ji@<~~R8|Fn;=lfKZiz(&F}Cg$i~F9?Bz-aFwzXKbAbEsYnSgs@;quF9Qr`7lSeJG!HYAGK9(TSB=Jq{rcuOX3m; zga{)wX>0Ct<I9Hx@CABrppq(2`W1q8%y^;%{otULin|$m&a&FMs31VY1`Vi+w z@?>;=ad@=vojS>UK!`FuMQ}O{(1D(oS(;J0CK>>=l%U)3=WiIJbVQFVRYKQ~uXLK* zwfArbK4WHq@3hqJaw>7c2dZt^JAkKn&*~`>cc7xCkS#$OU*uBIIQ4Y457sTfQhon)M zB6f*fiVfdZ@RxH$?{LL2M#E(@47b!$CBrc3?K!^WXt3C$K>g(Nk1yQ@b8~rTW9h&D zw_knx^|Sx+|NOJPm@CWiE`9Rk$zT5EXO8vLTPLGq8|3sD;vKYFu3x59#xRq*@G8Qe z4F~=I^0$BefBs+p_~gl{`>;QJxOseHk6G_O{c8QEpMBW79Otlt2pK0v5Ce>$%UZ#7 zL2rUlVoN907G4TYHejsRWQ}X%FKs!r=v0};|Irh%RzoaOhBI~4qIK51LaVYHAQp7R zS$qEMdVDq6|C|5tQw{q2fB5$4OJ?uLvj6CHPNe5=Bmw(F6$)qhZ^Yv$^FZOT?!0NVk7}^5BEr<%OFk-;RI&(~Up> zvyTte{ql9%#E{*0Y`_-cPB$vs&U%7$Sxl8a@~R# zZI9{S;uw0z{^mR#dplfZ79{8#&6GUgHy!Z??=>uw%H~6wlylm`?TwuH({dSqKnWliIS|P*JQBk+nB+N*hbbEp6VS~BwdRXm*Kxw{H51D$QtGFtBm3iy;Z?9(9j~s zbEK`+sM0y2W|ZB)_>}=SnDx0+9U44UTGkW9g4S280h@$?qi4Bn*~xKrak9{xxODH~ zgF7F6xc%+peNOMJ(kCZ9cX2vI9`Up#jvSeo0%1AQJxWaGqA?VN9Ng3|c^W;~-FSF! z-KpN=sGUUze0${g>MjMU{;0Ycai3jS(RWBF2vsx`$k;c@@o z6M}^bvlqX-r)20Y3N>sb70GZtaW$%g_CNdh?qKMSfJ^5|7MIT3gt9nCT3O)bVsXv@ zVpW*GEIVmb1EpqJN10)?R^|_3iVS1;uputZg6a}iA%AasWBI~`7N;lfvfWu9oSz)5 zE-u*lu(iEqrST76JsBT4e`n?1_Kp(xD`&iMB-p~WRm5h9z+iXp-d$OF68l+vbFxb? z+!%|g0S{OUyay!H)>@vTCLVU#wDJp4fGMO=iE)FLnqis6&o&hVPv?}cVbF>6F z^3NPB4DoiTHq=AL*7HL7j2wSzXbo1e5Vfv2M`3=3rlp|QOFralCFmG3;J%ATF)jS5 z5Zp?qu{w6JHs2fVozgFvlfq%;^N2H8VULeaqNs6EiY^>P#xvSBV(!T~+9Tv)Hf)si zGv~jBibz+R>h0Ny>27{|doymNIRcA5>yjdp+*#=thx>;Xg6CbuoL!+#70OApH0#m$ zI6_RnFOLFf&`h;Qcv1DDQc@|>&oDMD5p}m(_NTlmFDhwfrrpSfd1o1K(X^wlZ&ej` zL^`aVa|W>dR3UZCbd8{m94=VJlmc~DFXG|rSXH>fk>Q|B~7Sxp1*CB zN~>!Y;x9^-NRcekt4Ug&dm5-Cnm`mPD0+w-OfYD&;D|#uxSkzHeVboD`sn`a*ZX@1 zt}{?(968)b;V|W;^Em(Fio!x67>e)029cD9DrwueKz``iF+ zhqS@c8Or(_kqrvQL|(a3EK2)ijEbt2j$XzU*iQGuJE1l7<(3o9qaQ!vCIe zsE{gYS~^LqYt-ie?57=2XLKbO&MeP;^1-&#X(AcYI`Zh^(&jWrZo8hAVBs}$afTr9 zsU+Zq(F-_0+~Nbd-vlWOq{IZ&dREmvSK+?eJ2*S-{rS)D-`!q68y{}259a3vOm46D zj-Nm4*@gG$(W5)-o8P~1O1d3iH!D2rsM5iN_5s9zckZMffUK`)NT0+&k!sGyXYt6T zR2H;47j4uQ;mezNL;o;fy%rhK79ooq3ewj~Ax~}50ep!>yRqrn@ewAqW|mn&G3B73 zlB)ObM5FSFz#ul5^Z=Pz1ZsmaL~Ry z&GX|eZwZgwiT5mdrkVd2_4T6F~PyJ!I67irwcZojA(8>i+sw^B`Bd3Q-CCg`g<~$pKf$sEJyUdJ>MVMw%cUE0YGs5i}ycBy3gDm2<27RQ4uRM{Y3NVr#_La|pV! zgKD*=^;dfv>;3iq(iyklo9q7i*0+xzKY4t#wnmM;WAr^4yBwD+Go5Mrpl*b!X`1)z;b2MW@#7@R)MaN27X@4Efh;i8kRgcF*5`Lx82QTCs0+D>c-rW}|6`^Nu^E zB6i^`iG<1}MxdsumALRMi4hni$Vx&AX_BKT>8Al^*@9|>FC8FuSsYzL2j6O9{qr?C zye843ip2veU|Z!ClR2{4V_~>`4PwYf;>=F?l4?9W^k7}d`UXsn7oCd7Om=-S9Bh92 z=_4k-@1BnNl7Ii=*#{qNExSJ4Sjs;rRwFHqvYd%v98wFxfi%Z_=5%nEi`Y@mf!bOc z?}V#!d)}iJ0YQrm&~bzBj@J^&PN(n?t+Y^h52ej4!#&hzP3r_`a9Z>T9t+{EUli68 z<7YK*QuT78)Z^R0u)@O^kN-Q&iEq zTS^O|{Z+~AB|o#IBqraJiexU%^ga#Orh_L(XT#OGovn4Jp0IQeHu@yjJ}f_frM1r6 zeRywoa(3*bG+K%~RP?malo-tbU+NC2Y-`?fZ+&a^!3TF8I}Z~0Bvl!3=__E-Tg@2s zA>y6I+tOk5%7QAu6#V|mm?pN)UWm=e8`HnKioKIEq5#?9U~|)Qs2Hmw;kk6XJYBLC zhlUi^8>Y-+Zn^BNL&J(s838lg8|S^GDxu4P#dsTWENNM0BgB~sXGE2?YOTMeue7%$ zNYCo%5v;#21k##C`WRAALj$YT(w-flRgm`fA*W4q2bLn0=qZiIqJF@|uUkTPWSy#;b%``{j6Mc$GW*?S5 z*kRL9`VJQ-pz`p7^9U)3ZPQQ--Gfmc#eFSn1m8#3kq;Tk| z6SR0mW1VRebeVs1Xl+-%pyZN==~RjDx>?7CeqrR>6-X1->pDidU%pn}WH;bsOG~HD zyM86wM=7oRj)RSu*sylWgcR7}PD&D)00c={_@Reln6i-Of7#!6e0Au!x|A&1 z1Wx8uH!SEQu{K1=<-c@dzI7w_hr07J_Moe|Q(b$Fm0)>s;+hkJ%D7Qx4W36L`|h_l zH>!|f3c!&|!757vA)5&)cD>bkpe8XD(xeJv$O)PKYG3oIzIKVH?|!EH10+|j!60uvtxujw>-FbJzR6`ko~?WOAUs;$R}b5 zTiO3*{JV5v-o)lw%0(ctqlNovyl>1-Vn#DZ35VpUF#LigFpU2&LZba|7>@YG0^XUC zGQGz*Xq;OD?h>zQ<}E%In*hg`8KZ7mPUC} zqSck3>6s}p_Svvk6`A*)ox3q&WRiymvnkm%CW6JS5EKI`klO*HRg@SW9g8H6;l1F= zU>tE&sGFADwNQfj%zE_<>rsKx|Md*u;jzfG^uu)73|dAm#~ac(-HF|o`uyy4ur_#j z|K2B$Uh2%2tfP21dp}N-6?A)m>yGL9X>N)xJmEx>S@4a|ZLmMNyqPeqW!^eJY~zqt zMIP(CRyO!)>CspNuqh+j6QpTesWN3Wpm=74D>POj>4bF23f{F2SOF)pA#|8EJEKuy zz9r5RL(s4W19tk<%?<0iml;wNYN|t~QTQ+Oi7dQlkyRkYA-n30+)9&6=$Ty<0C^_e zH0Y3xA3}m#pG<7}OFc$5D-CPQZbUwL{rc7GSI4KPz0K`~_4Re-uqV(-?2pKyK{ob^ z%803yOwLm==va7G-K?b__-dUaLn-WW1=9glNf#lkOVca_w1y}T%ca9MVFp{RXBQV0-rVjH^cMBA0|j)&hRgt2l~oJ=LO!t|;T#feD4i&r zBcqTE@oIpim~aiJ$OO44(0Z|q!fj2q5j*`t9YKHo1djSyPXZ{aiJIOGN>2q9O7vj- zI6Q&lb_vN=;v!>X0+71SACA_Gi+5US4wGtEdb$sX)%XWrlWd1MvBPnfaWQ>1H)_`xvHDqnh*roFxWM&RVBmc|+p6U4NS ziLAngL}k0GOWKi5Fk{8i1mVX`%( zlo9GlW)e|4(#?wxW7vtnr3BI0 z=ps6APF;)ooQv}sDwGgffSMNP5Mfoh6^rArz7X#q^o+Mqk&MjIm?CBx8CH)5tn2$Q z?RxDc#a2v}-;rTrXyG%!HdVdf)FEG^`ayv9>4i}RvUtu3nxcTwqle=#Fs8n=5n#Zn z4&|qIL`|-ENz)S6uPnOes+{_3MaT`3Bnb~yG% zDYP~4R5D)eW;W#jFHLZ(3587UAcRZnoVF`%RFe-{E`$KmR*1w*QVTv#;m*Ul7(siRh8?j6`}t~17#W!ma~s6ifFo4PvH z|7dAB$|K(#t&aVBJUKf%G!Y=wx~tRpc$-g+EHQzPj`(`;qu-GADT@18I_0jS}Q_nYxpXFdchI2 zU;>K>v&>BgE@Y%1=_{djDc4%VDgpCikv0W@MV8vw15oMYMMNwCIewPpE2^X@Rb|vj zjYUVYMJ)WoA+M`wK7PxV_Te9|;f9bcX%rmWGDP7dcNoj6wse!y1&CUWAY^lp69~e% z=4RR7nTe!BW9gweGwPIw5qKYck>`It|6uF5*5|wc%Xit_( z!V%a>LbhL#&LMF=3N609*E1izP-BDV zvSt@+$2}H72?U6tE<>v7s$Z5|vJ7WLEDa-^nni?I{Il?zpljEzP(>FjH3%eXFTgBF z^ErY8p&J~1@B&Bmk`cItN(0^3rw$&iK=mLT(dVRYS`T(&Ts24K|2)SDQby1Ul``HA2QboCoEWA=U zQFezZO)Kw$mig9Gt8ZbyeJJWTH7#^2UYtHM1CRsvr-ovZm_mA2h6IT%#TQQob}ALl z)?Jxt8YBfUkkk0UnYx2^0mLQw(6JbZbkQC&L)vlFj6o1y#N<+0! z&oWepOelv&7DFRfDzwDD`ZY`7MKawo9j*(Jr9}k=8x!!xHV3+b8K6;~-sIe@;i}WKC?WP0-B8>{m%X(?@BaP4 z;nBzlYa66nmW+bgCRY|tcZvcds)NhFrhBv@{|cXE`y#eu;n9Cy*=8P&E5Rw z@}e9s)JjjZZRZC^|BGp`lva#=dBM9@NhF6Os&T5WMGTKeSC1ZS6OMA%X<(Kba}Xsj zlHTYj%V?G%!ZRzl1;+p~t_})}C8t=zI9=L`POH6;){V>|ur%+*K_VNv@+|zzmiIrB z;YUk)|A$7t;CotEUaVM`N_CPhu$d4|AeV*eFF&kOCTjNt^~t|0&bKmbWZ zK~!IxOPRW{->n=R9Bps!Jbij(^VR0o+S+;?yfB6#6#1)=5NOu$TQR|fpF)bQ0cn~y z%@N-c1SEEVlAs-U&}p$qB679X+QL`S3X%nLYmf2Vdkf(YMMlbJ2$wHegYf9i0#3{! z5wL+RVZ@Rqh0)#PgVpWr)4O*zZH&}icmddzaN!Dx`$Gpd$#`%vcHR2gV6w64kSeR~ z8Xl1fhU#Pn!pKb>W$$3Jv)dn^^L_=wz_uBJqcJ%$#wcV4< z|Ckm(;DZwU^Ze#`6s@mqL2)zz!F?z3xzl-LP7%?p1OQ}mk3J%zZMCXtdj{(24CxO) z#6rcSknk{cw-u7iE)|6QGnzESHir0-VtzEb;lGHPG#!n?kR*~08bf!jaA`ChA>IIH^VnAz9k*h275)%cJY74Oz`$#1p>fZmTWDwvq)fbkEXt64!lhnf{yOnds286~)k6EWo(J0hH-Vko3AUd*o$?Nw3DnbbP%l_h^r(LHKz zZrAiuHmRr4x})RIb1T-dpvd4rG-AP}1&dw1I~N9z$np!@i+ROCrsWN^ULLHjjTR;c z2f2%DeUqJmsYg#VZ!be?##n7uJ?)>B}~(_tPM|jCZq8nIE^Bkh3>euWJxZHf5b?1 zWPDj_QL3r0?O96F{G1uHuPuG7s8tynM1pv4q=borvV-7X{WE2N6cB#ErxkuI<4jr< z4~PCO1cDAhZ<^h;yrp>iSu>!p8U&Q8m*G+*6*D7PzUU?d7euAM(K5)dvVWdCb(;2;+Say_*<59^r0ywK9OG^(v z`p}jG<84TfrQJTlvT*7;xN|#t!!%J~GcbYyGh;op0-Bxz*7PO(Q$EyHK&gF&&>AQr z)!rLYT?MH0H;GZR2>Rai|JKNqR-r1%dy6sr=mHg~XGS`N78FeLPb&P?)x??1SMHF} z*6@1-@>HxSBs@|R<5~Hwmf5quXP87Le$63PlofidFR{gE{g_2&W!28jm-`3T9}n4R z^#ejJ!`C(QL43R{+z=4B0-FG_mR8)7F~$n~fYN`0)OXLAMkV@m;KWPS zgw}UjD^RoFADC2HS#`jJvr~Wl_y4%L*?ai#p}`o6B&d|e?{1!yKGG1779~h_=$#&X)Ug zPT{4-pdn>=ZqeDq%cJo%ec!`Cub%V*Ow;RNSe`68-jemLXZ}x^%<)|EdSDv zy4dRZp-f`(PZMS+dhd%4-txAmmbPRXu2v|3zr0~Rf8e4$u?_>1FK}7uY_AVqy_b~n zm8_Az@6-hXy54gNUsy54X%H&B0bQ*ss-YlKzgpCqkzWN{e%^#mbaL^uyDAe8fG3 zOpyhVPB~)2~bPS9ZwvHYxa}2WS2?2MCQ}s_@ z-(Vq~6*xjQ8JWu9!*B54W|O^D;@ht&IpIy!4Tp_fAO(L*6Ba8`3nV>CWZG5PYu^-5 zyPt2@@cv)$g^8I?NppdA9QA}>9b=ADdx9tn!He8o*EhBfj`og5=Zk~hN?bQx&gwvP z6ZyzZOyRaExTI`p!{nUXrjt|7W9HR!q^SVsL3jx@^fKjXko%bS>b%kYx;~;DU#po; z#Li(Nj7KOQcb=g!T;oEWePq4FSHj*o04m`GPg)G$FH@8EPKe( zso}LkrS?eX87TSvzEGuC1!%6ORF@D0Rme9*wO*>g`q>^RJ^QKYvo8&dMr=UN;@^IW zFja}3R;mroKxPgDkP&>eXAC+80s6sCWg&~h^hy(BV%tsS&RlSv%NZrq3D?%Q6-gHY3IPkd~2`K+#HVjazF z!C5FaO=D?o46Zc2W>(&#J7)ojw?TE6x7++je#!f<66=p-|FKU~rfg(Ps;VXAidtcT z+9bPzMj{B{L*#f{VaOz}uEO`Qqhm{)XU%SBeDV$eYN;etF0V~0W$6S~T+aKyJ3G&R|Lo_e1KQh0`w3;I8%boA_SrJ>T73wD#q@Pf@5#E-=A!P)emv$eV_S zA5WEuD=<1ZWN(;hpIUXE)2s@2h;(EVaZnmXc5MXibg6j2rAQpc@kBI{SWOK^G}Zsg zZ>2>?i9n7hBL#s`Q)XK&GLQixlGl8~j~*hB;?oRQlGq~lTVgO815Tod7Fi3p?b^hu-~uw>z`nptC!I)e7> z4=yFvP>_jt>*IOCaMGlT#+gB&Dh=~h^$#9J;D&+VR?hlI?V9NXfw*J2ZYYI>swEP` zi=egbMInNpJ_%k5XL&4|%fJVC#0n+AWowY33F+P7Q$h#*58zFNa?{38F3i&L$StPH zw`ovTBl9%kt-pXyWffWjJl{(3<_G*H_>PGM1EE%tZl2;>YiCcTZmw_H>DcCO(kof3 zajMV;r69qnFpeEwTGf0mer|G`=Z)qEh3(pUi|r{l>v!%e1WHo~>qL+nnlBHxIU!9h zFWF~p$5~y@sg?Y73}(??NYbbVS>2|zW=UefqHPR_$}z>DtQn}6vUQ{;*EPS6^ese? zWjOdv>u~s-LOxI7hN?reMHrW)M5Gml#>={&Bcc1$ACrOE(^0}ZW#qiQXgA83- zTLNkkNY9Zc@$U=JT1}g>Z#K_mQx1{tf&8|Se!TI0}NeLnTA;5+RYWkr7G8`@MY`U|utStqs|u5tUzK4Y94p)@ZDiT5`5tSw%z5gqG4%E?--uXDT&N z$L}qDwxfK8D{{5LEpaq%xj30*OBCS|M~ZyCU6h{)(5e7N6yuWr;dU!^95^@_1NYjU zp4P8aXQVW2#9h#K9oF2@=r{y%G##aNZq31kXD&$l9DaMPMaCp@?D>c{@ z04840y!Nbi=5`)>tFI;Ul1xrCmL>fP?>E&o-9*u`h-s-3)Tjg@U@RJJ-1Qa%5+rc0 zUM6GNR+m;bwsxpR3pT(y1Y@|d(jWHM2(y)6nX;}*>#Yuu))Sz%`SbLI_A~1;qcI-kS>_lopz3)bg>o;tA&C<` zSW9XYLxf?{04SW9yu~jiwP8s zUTIRwfC=EPuWzgl`%x8OB23{x{?uV^jsxfl_R+t5^4*<{we7nbISf4w#{ElP3J=Um3a=6*Jzj%;mg>o+H-lO^|0t__|&eaz+Q)6e?W zCLD%ng_(oR>dKH*r5Df7_g}s|c)53Q_x|b!AK$xj5@R?D@Qgz7hSnFr7w)5Hw~ia2 z0|Qs-dW!ikZK5qMx{|-YZK9j>J!?9VlkCWG6eKu z_2wnZmYE@|rAy&__0$rwp5P{DGbTnB2;GR-5Ey-W8fGZ< zf(d6OgG&;T0T-cNqA61Rc#BlSCK zosORwhr<9@`s2yf!O;=+-?{hDCZOy|E>jF#X)<6DlL7OsmPOP=U8`0AuxodKn`#U# zefu;^-~fkefmXGSk9gS9K`=_gKj^KOrnC|Zc`poD4b6E7F~aiLw1GaGcggDVcFqh< z88sAH_O|k2DwFw@vlU!-CNJ;LT2GDfrG#uiF3hS4HN!%Ldq|PGr&w)GD0U0f4cGOo z-W!SWzJRcZ*1iDDi~m#Kt#aFE6>45euHUVSX+;sE$vZ8|dt*VPb|A%VVL9wj+hfW% zAd%7~y5mla(kTV%%C_2e$6~a(X~F`)j~1L69Ip`}g0&=aU80#=Z|gkS>f6wdGa_Rz z^vUcWbAy3X{g`i~Pn5BYM%`KOX@(qSbD12w_{vh>2$>5P!Gr06%3UEkcx-J2M8l3( zKWFb9D)M^qqX{ww~1o%`x=Lf3;U;S*sdogLx z8nFcNvaGM(V^+9WG&`?FlMG8CR{`d03$3EtPpBY}#Z>xuwq=;iI)uTd)vG-`@2q8A!fkCa9z>q@9PV5COIgO0T8gT!5Syg2RaMo!I4tn< zfZQ0`OJK!~sk({U3+o**KwdvH0BbdIVxbkRk zvcf_BrC9&c3uE=H(IU_TH2>#M@DD3UXbB21@sf$gZCCor%U7?SJsIEI?S1;mqw|Yn zgg}@`m2CT=i1zsY0vR zS>cJQDyndW)Q>JEOd_}cr(fTE79(qWCQ(-`9F=g75vOJrkVp&3u@syo^*%|#if5EYs|b#Zg3h`;%{@u~i!_H<3%348cRSUNFwoGa)L z57ZcfM5nXYcQrP-7>tWGvrH6J%-xJHPBWf{&vf%@d+Ze}x!ty@75-HBNh6HrrGaW! zAS}Aga1ywG5TqA6o+?K4QxgC-%jjkHUQns0!@PJ!dIr4}T1c0Hc>^GE3>_Xw4{eU z#~YhFr~9w>PbS|zeZ^nI@E5P2^GlP%xM%V!m`vCot_T}aD?m|pEyaY6I$QS8fsV49 zlkf>*ohtOfUUb6-LxXgN2kjHXR(LkKBw>$^PDvqpC;fYJIbO`3eI+zJv=*n~v~syr zqnuW3E6}_as>KKm=Y_yG9ZkH9B16GBZYZ7Y8W?W05H$uJu8|SHN`Y;?BX-mw78uX2Sx2IKVdis@m5uI56OeXWR5)5{8T5~ zoGT|T+}+0$j}qs^o7hsoemUp1A$V=j#E6C2W7~3AIQ0V1X`i=X40m-hC~(ZG3=9o2 zZxtXSko_>o(<1XS<(n20YIO*DPi9}V>7AgEG_9WRe0t}n@e`|bBNFoGfDDtmcu*GP z@m+^sKQv2TeJmxwV70RtIvtogRFEe^#-KJCbg!ZalvbYl3-!JJip5YXuxxowxPg=jZm#tw5H>B3xbq#m$v1E?9+iRtzHf6ie+?qE)K59sw;G z--`SrKU%l;Y{-3NJqvl;)Ex_$Oi%qgQtBZi&o$gjEw& zFh-M!iL=0H(w{QX6)>%|71*AEGkrgLV*IzFp+)VqT8M-Z4@5^wkIMlUplf!Vt)26W zy?^-4m;d3fKAl|TT$XS>o-lh@aCG-%bDQI;hSKZU1ft672}EssqPtE-qsau0!|=D zXldHJXAL&V6~3AVX_Bk>FkD~!8l@sAA%yZt0}`|#T4#`2zcjDrSos`s8}{pO>0xEW zL|NY)2%0r~XEP`7AeS7+;(gH~h01KxEFVQu_8AUQ}^eIot+fx<@N_JE8DcG}@R_EJ4 z2~}5bCO=6mK?7d1jmt{QIl>W|YV(3+I^_|v6t@P3M8zTjEg3YU$=?oTK95EX>Y?SvL_1-R6&d;GL|Yjzc|hyNa94Ac;2ez4T;~0 zlrA=cY2vO5wb??d&!IcWGp3TW4$hb#th;0C{MGB{r=v5ba93Wfy0!1pdQp5tPRFB- zZF@bFG1k{eqUa4JOkul_;29wlc@-T}S4nfy&<=xzwEXr`J<1W6P~rHY-QC^Sd(Xc8 z_LU`|k3N2IG9s>l4EzLdFqHq_4w~Fy4ze&omD%+7`qWrDRFSuRona}?rv`#_tm0Cz zJN2v1JOnSJl=%Fg0~c^Wt3PlEg|?!v^}}jlB0;G6ex}X!zg(&g>*hNPovGL<%j?bj zU@*MCT-kfI|NQA8`}|M;{OdL0`AWx3Z4-ZDpOQY)U>}(G= zR*#>*T3T8QR=}YV8P1vxmoX{BvxrWIRe!-3DJwqxUJO)3I9ut+g|OI3_U@}R-mWI6 zX~;CB%@rCBgOppe@Qy7N8*~PZsfY4pWH{-DUv?XPH=k};f!V{%f5 zuW1^`C*}Gkw94vdAQDp$_Y7q5a}oUq;UVIQh=m(K=L83 zUJR(NkODJ%hxCEc7N{07MsNq)=7!aEqaX##5hvVTN8_WY7rH|64JxM1dPPl~Z#6-l zj?yi`J5S{$CZOd{J^z(QgZnm4RI0`wX47PXs!$h+EaUAoeirK|PRdI5*1;KN7O(S1 z%q=MBBv`nsD?@hOGlis^rWH+y)Xbq8)g^qBUSer-B4HDi9V%x*+?iUQDYBcd#g(7AK%;E-hbjX&v z!fi0aBmpVvT&;cG5V9{{?0@&&n;8C5S98`~_dre^da7P#QKZAi|xA z$upx%Sh|g#5g62RgCuFgHlu8fUNVV3K0G=+K85rL8_W0a@7m(UsKaYbQNZ-i1rs#! zZyr0<_iSg!sxYscYnBy)>+tv}ZC6i_Kt@H#%Gf}j3e8GU5A@2+ARwW1rs=gw_r?v= z(X;*XC6kYs4%)DRGt#IEYH2|^Zwjp%+fSSpV{_HP+@gfcG8Y#nr=wOj*N3Vhvrj3l zG{hMK9_xNwPw!&Z`hyNiM#3ggR1laNv8&2LP|%K)Vda1jB4t($>QEp*r!a@)DI@7k zyHb9B7JF8jNPg(9qW*u%{_M%GBs=i@0ze=(?73G}7K+PgG39t$qjy)&sm&37IiDdo%>jE3e1xu>eH5##@u^aHDU~CS-6jM<|XGt5w@+ zHhqmT3J9;-4Lh(a?Va=0bxCAhYY%2@Y48y>A>R2T&Q+0OCbp+Q!a~4%Y5@cMbKCT!WhEWH8KM*@r+0WLQ@z-FF?rhh|n2i%X+u$E@CQ`W{stYBd}r zoc+G*Gagk}d58I4^HNu?$r_d0T5-!pgTpG28B6ZI6PHu)J{%(!F~}Hi;Uce($~a9aiwwSHCzuKK;#ae)Grw*{3N%qYsTf7h;xi ztpIE+?&502Z6;--VySlykL7{2%6dkFdEuYUXG=LThOefr~1 z@83W2h4$UMKGt&RdOzgG+T<{k$k4?&q+8U|3I~Ra z1+Qv#1_jZVtpN2nyEGPX;vdO6V~D&D&UWF`jLHGS;3y! zPKLWbs^*GXv{ix)$_jF~*o*o(TU zH}SG)^668FeiwH#pwSx_B!~{WOvN>Ux+9H<0iXu8x`Q;EENihA7r+vTH;&|Z_~73C zdxr=2j>f48he4!jqZ@~-Kq-l@ZoNj5rSVS)u+u{~7 zm1eat7GQ!&v%5}DZ+%5PmiNTT01qGR-Ft9Mci-N!5jg|RjL^J=eReEObZ~D6rdrb9 z#=p1Eo*rMGUOjy9-s5{u&R(59fBN*z<@t|)^wGtcFH4^t?BC_zX+qO5l#gwR8JJ0d z(U!fb9d;kOQ`WkQSz(x99eyiSy!V|Vb>zW=1K%P3)vsRvnhf zeJYjFQ>A6WF+fB*8_v2YswSPk9J?`VNrQ)!=JeMvG%d|$a}yRHsaZAOAI_$1(@tg+ zvyKCZhUunMTOv0pCQ=OSDwi1i1apdE_>yWH2uc!ntOmDccF!796Kk`u;q27$%DeA> zaPF3I zNmbP*(5Yla8H9{3b5Sy{BX0ZWrF-C~pqh(J0=>L?CpdK`vDotv(fa9Sf~cL{)*3|DJTHPHhk{0C0g$kEdqPK2}q6EZYo zk^o?#+X9qpw5Vymu!)F2R9kBefwhYe0uyR3QO5$+$|x&Ptr#oMo5Ui`)1{ATY@9_+nU zi=0c+YPt8`d(YO;XD`3Gvv(ixDtH7H7B>*GZq`-27<4VZEgo#HGKXrWSh73~=YZ|m z-g0>V{^{TR_1}H^;~#ze(I>R-`1ttiufP2Dub=+zcRN4*>5i=x(BJ}t!?s%Q_2R|F zeP2x;7cx|CgcJer^lEcmFGJ72G~r!Xh;1s))VrGELY~mvEZvZ6Wt1b13z5BrM#JU{ zO9^ZkoZYIb%#yOSv@*1t4BdGGcyncK^1Ye*4v5|LxE2Kic23 zXv@U>_(N(N;ljoDYs?mI)LzukuKdW%np8omNVm;@_*CxB>$4ia!PI5Oo7qZ5Gp4nY zCXrKd&{rBD z@oCuK9I_M>SMW%G-M`pAzA<{X6-NSeuKbfYgw|t7!*_#g@im1tfYn>4Mh?fL4d?qC zOd>yACw@zLw62(dmiRg>6UJto%uYjNxjodl~W0wZtqL;mb zt~7E>$TYZL0daZ-=%9%IT z3yX}^&@No?K<%yLx6Py%ZUCMfAut#Qa$8&_iC+yWG^V)CMVhK*6EIw}k^0c?qMPYl zEl2L0zP@#T|K8rC4^F@S>L0#(etQ0&e)92qmrjkjd*{{bTPH7_r+3T3_`IYEH;VvY zSTWc2U#jq#cF(=Hn^47aWC2fS$dTywZHw?sf@$8Ke)IattK%2PhqjGkfw>@aKL6~W zzx>_rJ_o~^zo~7=;hW<_2Rbf%RuSF5i`cwb^# z-w64@Gm#ro6gG_*m2TkCdgo;dWZ*>-M{zv1K(H@b9nUln^9G0|9Z?($9WsB_U|nOYZrw{ z?1z5KOQ(y|7UZr}N1VLGs)M_Xj8sc%d;rAMiISX>&FhuY&vvGU@w~aEMzTo1t}7Ny@$M&bQ~G-HO85?(P5yV~WrmYUqZSgQ z$Ui-f@@=#Cuz|Eex3{a@VaU3WN;cH4ZtV3|mZ=Ph3x0#9hbTIYo)}`ZFn|`_;YmVk5T=D|m!$aGjyMMUGO&-nClZ z{dMH^eZD}s3yTk%`}tYvwI9+KHhf64*DG=K+m(@34WSJl=cvSVaQc7?p!Bkfq~X|7 zal_t$v`LSRtA!rmK>)rEj)2Q9Q$cFRu_?!hziF1MEp#u>uXrH`M~~V5tJ?=Z|Ln6@ z7q1WAp8epx2i6}vfA&nheem$e_f(hQ7XAyjbyb*Gt{%Nz68}!J^>&aq8lTsZ8+1$8 zcK5-9d(`3C^RJv@b9s3>Hq;{aPE6R_e`IWAa$#4K2RLK!(%U{uGYSR0l<|KqgwLdk zRfFm^O4nI$#weU_59qvNSyv|{}^VIh$ zPwTZGpB(?NJAd-8{^;obJqis#!vd_7@B8{B*PGW4b`Sh~eN#a~?=f!3 zspcewkm9|#p4viL1xj^ewjqV7&K_xmxM}jLFM|0EBUvMrs@QalH(WC6p}J_OIh!s0 zn?k{BC^GnL0aOj&83A85I~@qKQRj6(*J%a@LuBV4hg5@2N*c@Y*4M2w4U4lw&*pJK zS@D7123$Zjk8pfs^g-eBdi^v=IXdE!N>qa&fL2iElqIHG(S2~|(v2{~30w2DV%F>Y z*^#W5-484Zwi*m+{Ed7RKM#4AZYp;5@TZ)xQLH%ez3+c^Y=vrmz`flzZo}E7$O9+J zuG4Sq&^CJ>ZslbH{V$!LY{0>uEzd@OW3?L95UH?x>*%qiU3%!L1@q^crSBw>7U46h zeQ!RS8cGT_^fP8R=tWM+%-XtFw!(&{)$)}~!bOp5)Nh0P>&JDoc{j?qO|_UBoMNU& zTp<-8l8EVL%gQPz`DUI|quUxJ8%3t;LITk_+`s}fd0OD!pz z)rpPAWx$fnN^e}Hrm))A2tiO*A)QBaq0{I9N#ANa)(^e2xBuYa=-}b)yAPcg{>4|% zZN$5C@6nUHKYVlX)De`;F{Ch?qF3qbx+1@G-AtHlG@;g1y}~01hu|RwHemR&XCTB})w0lLAq z(KJ#|Eru@}lwx(GqN{@71IvbW-m1Uq&f=#LtD&Vvpl;yID{QD6+EtSg z5-8ftusQ-N`Zm@JWVTkd9W$A7CXvB#?hQN}7=a2e0gpKKz>H~xS@tS1)|?6(B<6nM zP3h<9TNi9@>TfE%EK9M2mIpB-538a~rV?zDY=GQ++z2vyzZJ3(3+{?$PbbYvKOnko$O6! zQZAaFVW|0*9A$OYOj4*J?GCn{Wddtr@}l{ifDF50GHlBgRM%mnQr10Pmo?BUWTpjN z(2SRB9yc#B89RJy%FBkG zira=N*l^EvOnj(+&* zr?>a-etrC6z9{3wuFR&|@Vc=%`*E*cS>82zgYCGTn#GdNsu0h=7|k7RVde4n?tWH& zbU$L`UD-$CgMDk+7XG*2Tso+zt@e~PmQMe2-oj#OkM=Pz8;{G!Gwn@ldLKUMw#q4+ z-jeLrz48)V{838Ie6@_FF2zb3<5IZ}rQg=!5e9c_ z38MsKP5MpYRPT*Qlrh3&Ycx^>k)}jn$>>yUS%WXDfNL#TWM4mmzbtHx&6uA|h_Gg1 z7Au$1hf_FA4VWIm%nnr_E4LUwz>Vd>?%~Ej*jCq?IozA{Lr57nI=Apth=6gRjg6h2 z({-Y${0=cI!C`ERv+a$!5y7R!1V;AcRRbo1665k1GT%X6H4Op) zthauEWqN4O<}C!^f>i%xRRxAW(w8gIdf}9gvpIOrk{e&K%?;7R zjya51ryo4Lb^nN+ZKPrq+6|rzTQXjQ$rKs3-i8L^D`gQ;ykzUTaF9u|!KE=GL$Ssc zb3x#05aqQG44yoH@%7=MbDXt78CC$<7Y)M*BG>?~0@g3c$sn;767Z({e92WO=FRWD z1aLsQEV{mvbiEDuqIzA2*W8BM_cyH7u2W#@Y`)dpo9m2MuFUSm>$f%?eDmV$v)?}Z z{EHLn|D&Hg{^&=aT-~|#^6bP>9lpanwTmp6R~;HE=Oo#;dk2R)^-5Q6b5;H#4ce3iOPLsU(0P7T|J8$!P4goe^4D_vTIGd>Hb-j)L6=jg282rqRy z-o#3EoNSiG(46E7#k~2G%S^V!_IFSq<>kPXxW$El@{6YQM3#{iNY@o&P*mPR(N*N& z^zKq8aS33XtICiSxN-EvyX2d@1^j082FOHRmvMbl^oEV@Vg+PerwR~~+fB@h?|H%$ z6u-U`zkNTiz?OYWX55TBC2=1Q-C(ICHGLZv;LSuYqh33aU zVP=)4CO7ghs$Lx=g$SDfgx?C|`_AGmA{N8BGm&kvfzGU#D{|HI(gPwVtG?Ka1+l%y z3~G_1w&|ZYMh9+L+YiWf5e#j|kW3!46g>jy@W9S+cH(kJ|4}PM1sQ|dO;hs%E4hSF zUZs!v4f&a^QG=K%q!9CwE|EiGkN{ND(cazD(^nUl&H+2PefNL>)aQmvcke!PYU%0u z$B!O;^wCFG$GS%9!a~AVg(F;D95WeQm!UqoFoP&Z<@2W+2N05f>$z5?F+te4R$?#@0DO$kg`C`^*Y(g?z+N?cMnrn zX|GedSqNN5B4pkBF*(Pvv$OBb&i>^admpH}Q$hBQ9^6-J_;=Q5o}FGkfBxdvzxwR+ z&z)AY^ZtjAe(~M+_Id(UZ+N=`Y6w12|~ z9<&=k8YE~?cGX`vW_98R=J~CvGN!$Sfb@<5WIU- zDMoO=3cJ3#5j1Oy45fwkkgD8OZI~TtB#_ENHA zp6okf?7*}}Hrl8huuR>c0rITJ*l2AN*_3;SKHEg!jGV?PwKpzv zb=i2j|M)k5_2!R%c*q<(It@P2 zRQdEd^OysIe&^m!5FdJq;u%}7J*PTJ5+fHc;z!MZ1t9VPC=9d~TT|HC@p;SmiDcp} zsO#-0)nqO2rWEm-+S&%wR@v&)Oa2lv1J`uP6CJ+{>}-oyKQ zKlUj z+~!-iEC_GWI9f7v>0BzWN#1B<>=r5Us-D&d;t5p~Q@pyV`K-&wr=+P3;j@7{6AcT~ zhm5uN!vuHRVE#1FBk+8iRW&lkv>jM=ZqqI*Xck$+fWqlP8JOdm``4yJxZq*t&2WKM zoh+Al#0Gx2@0`%}I7MzQWt(o)N>c@m$my;a?Hjj8WFvWGF*1n6`BwHXRS$y6; z=2ox7TL|mT=Gv=oMQk1a>tnTI8CqI`}V-7fFfyILwJ3T#CLM{o;-d? z6bqC_X*qP>(FKKHivtLlv1bOeNN$x`C2VzjDyo?@dpia#mZ*#QxMZ^h4x0Fz*H!4v zYr6Ek3|&(2Htkt0e^wH=sLRGgg>FMhvvtUlMxSGO^(@6H68_^)^KDy~`i}#Wff_z< z8iNJ)&UG6zY#vX6aLsZcMtcw>1t;bzhPCrZ5MKqJr5S{nfYHTz5}YC&7PbFJkI&9t z9P)I3Pq=23o9p}S>671+CpF7Dhr=MY9JVU^gzvI-ft5>G_j85&Mj6C!a&BLHyqS1C zt~CF%*K25@_kH0;f_GJz*y@hlJ$iMrbN}HF|J{H0|F-A!SUO(xf`a0U}&XbWse0t$|k0Y4CP5PT&$*u89(cqO{J-A zcZEeE41fUuQ4=$n=B~EoR^3u!B(1?B16$SWbF*zpSe0Dzya}BaHZq>omU^WLa}zhg zZ`)rmG>?ODWpL=9ee?C<0XJgnD&j0hS>w#JAc8AK*5R>@&pMPFKChki+#1u;m{=eq zU-~m2tzX&dk=5pLuO{&4zkJR`(B)@T`;tlejC~;UVM7Q=YD^||S>c&yMb#&S;;>g7 z$m{W+xZaRgLXOKOyW9pvsY{T`DTrg`yUYK$vOCR0f8+lnx42OCSuV@>l(~#-0|!a7 zuwW`Skc$sgdCZtc4OT>t?mFSZ)Q#B? zvP!*im3XDUM#jw`hxVJ?>ppDifyAWkU)d*S81)XaIBAO9G9>iHt{)E1)7r7Ci)i@SXdc@e}dC;PUqNt20d7^O^G(=O=&t_dE9pw+vsJ%&JIG=X=vlO3vAC!m1uVC>+q6}HJfF6bW&+1bRPOME{3yf7!1}Wq z=jJ&!GR0wFH>?{523Zb3lFq1NF;p-Fa;`EAtZ5`il^B}etJ1&SefsoiteCLG5Nl?X zEsQ2yi|6`Aw0i}OPM2u&DqJJsW>~M3u(^q=tidKpC2v!UUoS+ty^i<3H^Rh2uiI@X zenQy`C1!)ftxW6fQfN8Dc}$Xc_jRrDqVE^g@|ZytkCm%waS-u(u<>tWF!UHC+k6+E zAUo4$6(OIN`Ru%rDif)I%&xv9polzKW|#stC%>cLdH1z_`4;rt(M@in;<%_jDDKGR zXYTUqFSg$!%`p>Q8~sZ#7&s+2buC%DTg?TA=X09}P}`@Vqb*Tih! zizp-Hv#`mg=C8-C6v1aDE%OxFDMizPb-&hrYG7oy7hy)Ewot$QEAe+qE@q8dY}xBM zC5HWUIuQ!9(ZSr6(T&hFJuqSeOO~O4si*ZATkxvW2mLq!=GdSb!aUlv zRsAz?Pi3pBMl5+iXXel8wYrYV`0Fg-R^h%Tjzk-XGDK$7-xr}xzN-3k4s-WglU2Ef&-8Nk z4s@oROQ+<}^3kNr^H*mq^$D$UfPvw~R*6=N6lv|Z4_#D+IT*Ze1BG?OYWZ8!E@W3G zAWT7ve({S~$dqOqzutc5n#NI=7&}#WEXUHCQjUe&XwmqF^eY=aQ^WAgcQ7n{agIw^ zU_4c?Vh~X3Yx9EN8x~f|oRkp|&Ewc2(x@GmyrJ`6w1*tet5w4IHAxK@BxndueHyJF z5fN4IcJBDtq5~joK6rSz^X$dx$4?%=xn&^F(j2{{Eq!hxKeL5Rs@FnAIg9Dvi+ot5 z3>%C};}Ippb`VzWF}srh06+jqL_t)pP%p-#N~<#WI5C@MTDv(j+uH>LV_;U^O-lHn z1?&(bUsX<)4g3MM7`QTZjgUcO6E!@0_j-G`fZR+O;q{;$fPeV(_~ftt_W38De)Q$( z(_@QW=VQ$ECiZrHwTqN4-<&sV)wwaxeW6xGf~L`DM$yR`w3vCB+L@gII8J3%o4KLb zjnR)uO`!~uN8xND(797Fs{CuhY{;!kIV2A=v4EVKa{_2Fd}wsZCs_2R&+g=kEOr*q zst4VmPAu?bWT|PI`;4U+T&7+P3L7V|yRvAe1ejQZNR}Z7Cretqbh1>r-m)h_cvJOn zq39@NWo)yRzo}yO`_8?M;>Z8r0?nk{Zd3RN2>c0UTmMUX1bb z`le(nr7KM5I&a^huqiWKTlIZfh6%%gBJt>OzcIlIk`Y4&0~QP;%AZqMoD}}3r*)SG z6ZakRV`{XIl6Kgel{m}?{%f=%Vk!?4NvJ6Zhh$b1>JRuBxQ8q*W7&Zql%Yg;-H60n z8@%s3UOjHKOdxh>uFcNv9~`M-oj;)hrjU}SOpJ(DVx&>80Czx$zY3-;)AQ{{rx%LO z(k#Dqk()m*^I+NQ<(?}JCXxC7Pf(~jTj3*@3h1kmT5`8F()@Vv)+oo(VP`COWc?Ki z0W?Fh6yFy{^@vsWEXN9voTbF|i-n4@hi959lwg+9V%aDX%+ytEa<~D_%*S7Ea5j0y z7L^9W-`+X<-B-sy_~ZkgwfVIhTJ{dJBo%>$1>e>40y#zZu->~)Vl~;_uwyEt$m_h5 zW|9w(EI+|bvY`U7csp{*l3s^ORM3>Q1Rm?O&Z~v3>$+LlCuWpNrG^OfER&lwCl9Ee2CLOhC zR1Z*AW!QJqOocjJuW#rD5is-Q&JfiaAO2P*x`;JDv4TTNgzShf;oh6`fW&GofBqaW|CV=hMlP#Mo@tU`w) zSW|;D&i~+Xop*Z!VBNn7m5!T`6S+yg-gNbr37TiOSzlDom6V(22CGA-81%Qsq^d+S zwjedpyKgUo@7l63kj?H&0uFuAF^_jpN>Ag#JS{>zzY8TA%-==*p1Yfw6)~JIZfV0d5@}-G6m@@$kKmPM-ei*^84?pRH&+Xc_)-P_*I% zMY|8;#H>2?z3C@%VGVVHi2!s@{L*P)78gl=JTy;JCrguh%$mHaXti^dyJ%mJOQxVK ztU_<}f8b88tz=hT@#_`v>n8bqUs!KlWnAyw3%0|4`RwAWZ(e=yiSOOLdHCM@FE5|^ zQ0Vg)&nyY&u#yP(&&PlC*?56{M9fknH7wUMits?1WQZAK@p_SHstR=qJ*LA`@3S7V z;Kfkf8G>WH>Qxr&e5{T6iWf`Qm8?MU!uNh3h+s-DeTY9_H|I zkw$%%I(`nfJm7;qdi3K}b$DG%udORJ>ZEs-2OoSDd_LtAl?|Z+Q9QXg>r=nqxSXk@ z22fG1^jka5LmC%M3#{KQQ~!Z{aCCV5{Mm~aFNnlRgZGbY$mwI_J{lg*+OB-3F!7_^ zes$s0w0(8J?3T4JVKlbB?!5A0Fs#I3(DfNv*!|m0JmTjC%YXjbppZxMt0~7(70H^H zro?unYg`kcGzWKTMQAq>Dx6C@>AgWN4az3n*{^+yl6+o-;)Tb^jwyqTR|uK*G4w`s zF@N|RwCUKUzmgF|?3S=|cI&F8X=+;q#^bSptsZk)qYgeDn>q$OE!w3xq5dwEVk6Kb z9Vb(x)SO}<;mxi>!#@TC+X(i+1rZ*ILr*YAV~B!rWjtL{ANgu1!?*WxSlgh%8oLgD!eKOdCTXAg~<5T(K>*>-67y1I9LG#$!krBPR9qvcHo{V#Mfk z>8l@~z4=c+|LvFG>^Oqp`1Ex5&2x3$35QH3uJ8tY6GXVYeQ7=H*aK%&8XaRZMeE5~ z58K4ol)I>@$Rsv>2ppJtk!@oGr}ifJ8X+?4uPJZ#a*pHOv{^*ay9s<=Bn5CXK|NW? z(|-rwPA@778`hQ1I=hw}=Jt*n4-dmE@u(z|3V>iv2|Zj>A-CeY51qp7yvk?O)Zl zG~hfxvGI>JG|1vK*Fy`YIz2#_->MY2I13K*tqk4ABBJ+Aa701dB+%i&Jn*VUvPbrSy(OMvf_dU4E=jtp%BQ2PQHL+Z+(6CzmX{CK0dxPM2=}tq#vTHD<7kD&Uib8I z^p|DoE3*<;Q>Pxgkc1Rw+g-Jnnl7NTS{9<*wMdp6S2uJdJYoe|LE}Fp%MG%F!|h%D zcEV=`_#WmVIt8^lZ{6$B{>j-HC=VV!v{UB%*_pK zMfnEYt|$SirHa+BjV5_8&?}?z4Nz4-#b{+~1#?RgHhhZ}-cr8(&dKz4%60-+O;b{9 zCfS@xYsoLhgz#4ld9iS|vj>rjt$dZCagR3+k3ZUg&U__D8YY2tKFXRfzA=!+=O13v#-i3Ps!w3tuxiO9LV-aTW&tT&9i07#z*u z{r-SK>t#w9)?IG^5+TGOk-0EsP~hUN{(5O?&QP49rC_3x2_2X@5+{T+G-J6+oCR+I zKipVe-sR5OX*}D0V38#mu{C2OC!Ib&IW;(N_4=G|^lZ;6cOvc+>rWmX;2l0RxKEet zj=yCRfE#P=bw)rb7#A*PNO#`eyLa#WjH`O$Y*)=I_#|u*;U^z_5D(wny5JyFI_3)3 z?%aBH{M>SQtN-3!9^86;=9{@Zd6+t`S}%pfd@R5tgei)%{%f65$?!)`To`52ZBHPm zR?5z92MJ_oA&9Y)(o>vS-WumLHiIe2wVfT<1GOT8G%-O~G4nnC^-M)68YNEX(1$P^g1Kv^kh5J7qpD$^_6L>TadLOAn}Ox80Hbn6= z{;Y9Y)JmKnOk))C?WXY{of^q`>;S{-s=PXS%)(}o@Kmap8N&2*8rW_2ew8QE4Hn#tU$0h744H6!vmS3= zuV1+t*2FYn#V_F3Ma&JBWnx#PWH;Zg8SPcWC*RCG-}i-|E>8=#*vN?@H~{0%XS5c*QpjhZEL7(exG z^16sjPAdZw?)0XkLP8a$hj${6#h0@HmR4mb8u^!vd6t}UwWr_ zSE-t#%NLV?fyZ-R&0F0y)w{ZcEP<_TU~cN<7WVoLH`YMCZw{C<9z&3mS!~d6Fq#^R zbE<4c+B^3*;Z(D8g1+kPAC^)rf)!nX5p6R3f{92Z4&P6K7MlV&g~1! z#_t_5%6BewUf&u(X#MQ&oNUlIaQ#dDrrQ5~2RaOOgLZHi@vH3XNbzD;hDy%=FyCVcpo-6srKf8z?!}t-n+N#jl7Zo zLdQ6|?8=*iHGFxS^J~ca+>*jmvo3qB8=<3Pt>S(&M5+gEwNy!`Tz7_X&CIs!fFo^& zZQy1=nVWRFL4)*Xq_JVbh@c6vW{>z32=qS9gVXoew6Yfx%4lAIvAL%WT-sSBTB;iP zE64RUiDWf)?#yA{SkrcGqVsElbyga4|AS^!34;iMxgGO(4P*gbpddRRT~=F~EFR69 zgxDKBKAYMrzJ~VCQDnwvEf&jQFHjBRdSra(KA)_K_pRW1TWsqk@2sx|vM1YN`z!W@rx%9~pZ2`7eF(_qET>7*?aPc(Mk0@?oi*a}J%b(H99~ zm-Rf-IPN9yq!uwunwP8nqp?G&R&tR>0hDWaZGT?^L4{h^g>ZKK23>+M_y5^-x4g9_BjS)J-O``$t{o-}<*g}6b=43kA# zH~N(H7IE*d9N;FrO;r1vPIFgP_v(zlrm!PcpJQJ@pE$f)m{|P)pXN*qm0VrCV)0whd+_Gq z=+&F6&z`>c_|e0?y9bWd*1bL2KVTp)XBYhPWRKctz zsBxn)XOnKM!x9Z^nk?6KL4mv~X-Ja}Bo?+bOsb|>Sru`6+vm34+_FOE-qWv-|MF*_ z@7(qQO5*^x%o*@~4U1{@4Tl^(dGz_`pP#Md^&AP6&ZoN55_bMqS1BTc$-93Ks)L9<% z@8sn5g}EB-mw~^l3#YVG==K|H>8*4+hg8ZceTj#Ao0~b0a@kLVk_@8nGt2Jn-rX8utf2=2AZ(w! zl9fOcJbZOOHQMJ~F@S*1Nz{rJ$GfxQPu_jsH}I(PS((!`U(){#YMvX25y zRa4Yx$5P)7E5djfvQCpyOA*X!=QHylLb6<@v+{-XfqbC&$UP9pyity#BzGu6bxo3>>#ARgVMrHzPQO3 znzHI?4myo)F4v#0(#g>_;8Bj9Lq3-&Li|A{5{+<{p>biueFr9EUO7l=fA`Jq(dp^S z-+cYzk3K$l-_Xt8?epWyoj$MCP_8f9#(@+j@j%5Dg|o=!Tlycu$$HHJ=v5iXKQu%@ zT~AM1U@~KN(Xu46t{$N?kuE}-nZ$A={i2ncC#QL|!ztDX9vz?UeE#(0_|?vv!@3!Dg)|y7 zx1ng0gHN7(^z`XhylI^kib}!~bg&sGjjE<@BiD=vVYEV{F!~SzBFE{;s;XXNWI>Q* zy;?CIMP7%oa&~Iuy8;3yiIcCt98h&3g~S7YK>A53#ETw*hLr;88^!2%w4|3)W78C4 za@p5W$qa>r%VCmlke!TD`0dMMai;$huF)NIHj%q8zIh5!Bsm@~t(|aovGe)MFW1aZ zDAjD%*XA+o-Z@%w+yH>-kg*=6bhBbyJ<2rcQ^ZLlI;(IEWbVUz^Vfhr0!9W%Up(?N z0f;;cDVj40+nePsKF&)=@Yy^$)w?R5F04*+7n~L`G*kKJlOO(wc2a`5hEnsJ zda6mvY8STaj_*$$y=`@Y+MM~K2#!Y8(Xb5GA^{UKuiv~n*PYvO3Tg%&?qRN1pdL<$ zm{A=ja;aM14gj(Lb>iYPI-A)kE6qz0iyb|zGLYH4+z8CQS23g5O$cK7dy`SlMOb6h zn*>^sWn`7kE@UjJKud`o?OZoP^$}AoLI$I|h5Vgg{L_%}+?C!)1q;OMvROlQQMV~W z(zIRPIG0n<4O#1cehbE^(k)w&jEB9w>!VU9yN1zs56^F3zBqsN^Iw1d$>V!IgL?Jm zCEehwMSUM)R2gHhK*GZz1Y|PC>VB&T4^~+a?s5ejUs?0{W-}>-O_MrH8ZdZ=)9Cp- zFkaC)No#a4O8>nM7mdp9zPUd=bObVLrzgk9FJ39j`lye8_)#_t%`(-ptZRPmz*bZ$ zF$-6<*FMZ;`^9`b23v``u@dhgOPCqi-MbGQ0Q+}8|NNJ~e!74E;b~?|1KAij)ih?! zccHC^2Yc_o_vGUIGo#R_GNfQT#)uw#c+ZD+r}<2{@JP7PR6Xz<`AMh>E>cHR%nTL8 zoW&V2sP>vVPsJTl%85`$Z;f$FO$i47X$864g3-y8q?A}LJ*@Sj3JE0xTrn1fobGsWcU zZTxi-wvJ&V9t4IuR~c!OxADF->0+voXoBg+b=|n7^pv1-<0NH+L+4AG9MpQ#6|iT; z>Xm~wC2I)s%F-NlF&q{gd{e~LmtO&8os%NW-5Ocbp>E~RrL!}1E3L+9O-XA@v$#7i zj=#a{*B7UjFF^6_>kcYrR{1i1N!;XLHb^XLSc`=C|M9cyLs zWVEbT)40o2eS)5_%u5ieyd??XC@Q#Uqf9q-OOgo#p?3Yw$;nG@>}7_%4Typf0~=d+ z4<6xkb|X)Dw0Rn^8Cw8Dd9vq*dZ-muk4*Iv5$n^7WRZrGvbT+rv%77=Ym)40?=|N5 zLW$&AF}zVY+p%+Yd~&eoYpHvjz_T|_>)(C5cli49;2(bd#lQaPm2cGeF0?K}JCyOW zmDRb}L%1dn<%7URJBHTS;7v?&qoMus>#u+Q^WUAF?tJpe17|AYFv~l7TT2?xN{lX4 z8_iUafGxgjGKtG7V{SO(+SjBTeBd5Z-Cz9uuZ$yIUho!iZ^uacM<3n&;QbGmpcg{d z*F*^4ZHU*S65uo1u4W`9sG+NG0G_I^{bTU1?jGJgKDqkqzyI|YU%dVCAN_&y;bG82 zu-t}*V~sED(%JXkdu-19v(H|fSOIfT)qL^t`9~id8jt|IrkA=J>dsK5oZ&QeGv{D@ z=XZz=dR5OsbzwWn@oLm=5(E+`u!lMn&f7&Paj&$AOQx7T3R8dH)4T@3n_eV_uIbr? z$($Tjdyc!@VdH=3s-&F~MXNUq9P#QAbFDaHlhF8WOr? z^A18rM-S!^2dWu-Hknr;Zpt}asIM{RZ-ceoaoV;wnthLaFSuDY*(Nk&F?|RiH_BODEfLc&TwYjEdgthn zwXg<^JKa7COY}L4clP+@H3RI*ocom&WyYB|nxDNxN7m9AH7Hi``5dBbWc13YVQL4z zm?9m~tE(5X2lSBaiOsgLkkQbHqdTiCUt9)Sv*ujIty<4ZRzW9gVKD~4B z;NIg8-khDDUS253m@mm{Gcf=T$xC0DH+V_et_`ao9;Gt{z`;^^C_;8E^3~gMFUVH|ZWoT0r)?th^`My3*zRse;odpA zwR7C0n!Wnsgqowa+sVQtsdJ!FEN2~!QNQJjEs3ZqV9EKnYsi0tc1Rw$?D`2ZAgD!v?P zH39;XqKfJNGMu$cI5X3mV#-OmmbIK@dF3&0%2!C_H`wi*48|fUMBTP&}OxQ@QGUD7uY;)xQ8^S|Omr zw8!2@Dl%l*NOU55?%iB(#;izGZRV$bPnwnGd#^WPLL^+aVcnsqh(XiaYfRh-Y3@kE z)mvQ9Psgd`<~7zQuuXFkwpELpp)a7wWwa7~7GSfvc`lddclVFf(nM%A z)Z5lPHx+j4mc3`UTf>u!#K}-HFN!?{bWvLCG{zA*mLvK4DR-}lvcT#8WGr}oP2NpN z=O|yo&7p$8(A&FQIAFKunrI}X2w9<9V z3}U7aQr%=!!nbMkO6&OgTb6(C-#L8r*T4MSS5W@=CqG*3)=)a0XO<;T%^5wZcI0#L zM>1ajIIw*d>pN=w;e(TxFP%2tTs+CYc(F$9;!zemx@p-?3-}{6e`jvyiVaG!JQaT} zgS(TN_J?y%9zDXz>E|?f3aP`E148?uwGYldNJ3NmO_@b28z z@@Rb;uirm7J-hwvn-?!$UO2{R|M31dr!Rt^{HAJCq`u)-#&W%M8kPL*7*a=Qv)xRMP;T~RBi)c}QC76j{p=+_ZGSE!EfpT15{0%|)pIiC=VArm)SW zL=P*R-gKAHhrO3d@*o8TEHG7pNNWp3%AuBR%DN(SkT~L8Y@&6_bdc?d)0`542O5$UL87xi#Mt=u~8QAYiVJ)e|S%2 zRv&^fLWLXrfL)KPW1fs?4wa8k6Y(uJN&d;j6y;qLR}7pFEUXyfd~v2F(QQy)fao(S!q)xGWZ!`>tlPxhOqQN) zNZjDP65(BlZeLwPlb5cuvpkEj8j`e?+LdwiHuc-GBQ>|I>b#LJyv{eAYQKzZo@1k` zyoxFVJnE(0zJ2fhA(MTVDQ_)I3^gihNx&FWj}qblBblL;5p206ix!fJ%klQ=?DavX zd>=VZL8nZY`sm=`{^N%)Uv?ruJDY9Au_1J9y6>%C-P!%}**DL>IYt6+m(AsCTmv6c z@>~XZMs?3A%DhEtRx52!B~4iUr97Q2$HG5tN}ra|+zw=!E!C78FPaR|3B|*X;_?D5|}~f2c65 z^|bHI=Y1%q9{3U{Rjn(Ul)9;+Vt4Y;4@lx(RRarZ#htsCw~yZcCFewqL21FPY=(jU*nDKrLO?HYCb`QQKguYd7+@4+n&`RU86R|aj&cmSoK zU9K$!u-D6{Gw-Z5^@fa1^O`ff|KN~Keqhn--QBa3SB-SdD%a#bv6T2xQ0+P7WTi z4%lX%{a7u4;ia#*g~|x5my%ZrDtf^r_w<^L9r#81UxzQ>7Ql7Y5lM{z4sva0s48`( zhJd^DYS7LA50roUa$NwBF)EPjcZ2;uksT${Pkkttla%mgwL5*KiOTn%bQP^K-@doCp;LA+D$ICi zDoXV#xmT++pjk?@dtBF2Cg3gxe1{mu3)}ORvr18C5P_eC+k~>?N#69b$K)9ZpSmQoWY*yW!~QpuNrp zQ*orpfvObTc6SfP#&QFb_xkhd?EHAt9HB~>qXJvW_r{C>z79)O-Ay%z!G+BepPeEndGF?_7Jyhw)mZZy|87tHtoxU;x-`fe%v<)}oLqYt=J&?aA**5F-FZ#x+ z5AOmn8E-<~MrO9$U%#r1Z7zLPOnm?S54^Rnd<_EVbes4>XMKbT?s=fn`0kT?>Y~So zO_|F$ew*nVnCkIz(>66M-(y>PbwW#Muc+~}6krnonY$`R!wed}$+C<47Tw&-`zYvJ$6w7JFXzxAb|2+SbNG(_%^qE0i**{e9f8zF3)lt7AqRE8tFh! z8tW74NKL&JLmz;J&O7;~8J>!OQDnj_H3`dD+G=hNSF0Bq z_@8O9(fqZPCvz;BypohGj5b&COV%4Z+jB8IuL?kmporr1f8$G)2az(sL__EUes6Yt z)@Khs138`t1Z55~2r(lkqt8=AtP;6*@6ne}zxd$2Pjnpq;%C2n|H=KI{^6hg_UBLU zTQ`@4^WRp-y-{WRsjUeK&V(Um-L6K+4CSJ3)M_?h7{uIQ-hdJv!xeCk?j7h{dEGohNtA4(Qt)^77Yv?s&Hkr!kH zGbN04l5bG2!s2(s15@9!+yyB)19a1Uh$_$U|NW!APF8gU%5-`S3E_wqL0={GKuSX7 zIZ`CvmN7-wx>o*#^(g*2h?YoU(gris$|c5iVuB-XuD!*rxsD-|Xm~9KsDV@3z7Mkwm`IfF`V%<>6X;pq>ztVJ4={E8Lg(?w$9se3=`+>Rr`) zjpDai@bZM2={yZ|X1I0Umq!CyTSpW6ghlw2TYZQqP+OKj9gGqHZ5o1+rOdo;M#^$7 zLt+3+W&9WKk#ll>;V8wu{i&Y~ttDfnKzPv}fr-3Mt|=g`Nk(?8O+wrR?}wA~Bctqi z*J$#vXr&vFFz7Xs@vB-fw%a2Npmex@FV&{Tirt7NiJ$aYod)?`TgC%|YWvnIecG-n z01D^IO}$1tx7lNk&|_8uG`iehHezZ*{aceZsSEcZJB7u#mZ!FLXX90B?q+Dxb z7cdNy`aYtgSlL%jdopZlCC0%H141B*nQQp89m#AtgTRG9&^<5~L z15;9qagIi_aIE&MV#+l7rjcdf8fU+Ka`FwdSMWMM(2!d@PKi^l)C5t3mxUZEQkFcm z+DqN!m{BSk3j9j5Fp_h_&q3F#O#*i+{0y4q(6u<*l*8?Bp1t_&^RLZ=-oO9o!GlL9 zvurW{lnJR#BpbsusT1};INIfFW2VJ`w&dV@K8j8Q_FI7Q+G05Cc>>R|CFJpC@|wg9 zHN*E*C1f!bD~5cO7Pc(Ot5^}@hPusFWk_&s@7_R>Wki#6FhfKGd{j$eVyJ){!i;ed zl;%~sz_l}HTmIuWi2FKs|%z_t7TL-l3Dn3xMUiR4CZ)# zaA>*vt+(gd`B0j3nTK9pgA{lY4B9>U7ON^b*RGa)KFm})?0-XBwuYVo; zFxha_d~(KHL6)Y-#>15$%%`A3<4r>yCYoDS%M;F+?=~Rld0F=2Oi619#|$y(tafI% zFLWBR+_PCzL9G}ePlj)XFNQ%e^Z4cS0}Dz`cD3S3u1nIl1H$K9@=q1&+$~}>X=`Tp z^vtQroLS#%aEgg7OxD+;#yWx}mO%~XEeNWAA7>g*%zJAMXuBcI5gSX3_Tl=pA0wS8 zc8p8@TQz#5iLo2ec%)~O&|5y;&~7anWn0PR==J1&^JvdD;$5Zi>FhkNADzQ-_>FH^ zCDRlGVnThi>T_BBAJNY^N$5{RGA#+sN_3vk#~V=T8@G&{h`qh*hzI&U4#L!>Q5yx( zq_m_)FF{XL3kwg=U!8pT@y9Q|{_O7SH=q9DpMLi9zx=oV&p-da{a^l1C*OS5+9Dg) zZE7;RKD%Sp9|sgHoY1|@_j)uuUez)|HBuHye9RcaR|Rmh)J!a1TAH#lE(3G%d&Q3Pzt9I{a63uL%0rd)vm3PQiED#8!e%=xzQ9H z>>AGBIO^9*AVFmp5Vlc1Ws6-bo|-)G=dFT-&GRC9`;b2LYQ5j2^227hCMRvcI;Nx+ zY0j<5Q-ModXG<2s`TwJJ6`0nfg>+8KFW%zJ;6 zZe5w^)*(CC7&CV4&F#2@S$_&hDy*a#OsuJfwjwM}cPgb8OtSf?AmPk9q^T*=NU9XM z^hln5{gq`eCLR=wlT5G<*TtK+U!VATed|{k7^@GIq8F>lU^>%DVd|%-spA>h8NQ@I zp#e#HZynB|V&4W!a|1F`ke=YK9~D)q9J-Bo_z60j%`fHT=atAXu1U*yH7TR$ ziuBtYi{%k7PFt(KV0JY`NN6HnU$(F0?dy-?v)zL|8wXyVzy8S||1pS1 z4)NTk2(U@&STX9LC&NyU~b53XZP@ar|FEi13#$3JUzt6jsKEOlB=rg7)GZF zTO(=(E?K_(=JhvUUA_GJ|N3A3AFj@y-n)Bsbf9s2_V&%GdGoaE_Pwi)*{i{c;lq-o zAWUiX`eM^Ymj;J)Z*E3FgO$f|dpD}+5dbk~wW(n#CJd|NqxO?4usBBR=1Oc%x#*2# z?aQ$6Ovk0FjibeS-whQce(joyCn0g_qVn;4SFtB$ zZ?A)x#fvF!6FpffsqC8DtBM2&7ag23RKv|J(S%!Z#DyO`Oqeru*K|4ot*N$h#eUMwFA?qfYU{k(LG%CAg%@Y-)|{lhY&nHMt}lcCTy4=HhUIU9wZ`3{|HG z6;6YL;E!7M#*r?E2anIrH6BO*{eS!a{nama{>}gT=@Ry%GXYgmVw1emTFr4Y|bztWIiyV`C{mSrCuP}}(oOkdNL*yTa8MFEQ=Yw%GV=X2v^Uu|sL%b-oRBs_ z4tK7R70z9R0G6zj)s^oHatVv?lWB5%U&<8+pCH~Sz4|t#Z$RD1D`pY7;xvkLGv%;y z%3Z2A(eqfJ#`@PhQymE%w{{A)PeHj=zeQjsuRx^K>GHxppqz@Od0B+FZ}po}_Zniv z`DcR){`1$S8(7uyrpnA#P@PsB5un!yOaj_iyCz?M@!|!;J7_QTQ!nqoykv4=)NzMe zaR>YwnQ*$w)!yOkB;81DA?&IUBuwMV>V$qvfExXP=P1;aA>MC%yTRdkv$+h~-LEt; zZSFU(xBOX;V71_349Uch)fhe%@9OHF&q%bl!r6m{n`_I@j1ub!#hcI&z)wmudZXd1 z8f;6PSIl&EInI~NJIv{dd!(b%Y#3ok8^i5|2R*4zwNlU)T6JNNCr8m(0|icJUz)JA ztUN*XQHDL|r5)`3@@K#OAOG@i|KyK8{_zh!d}*|4=V0%_$1h$rDzO1wHQa=uAON0o z#(C`S}0Enw=0*B((hjLue zoz7g=X6wTFY(i;Pmz*wbDXaA1!YUu~D-=JqIHaO?{eq!iN5KW}^D1fVdb!D%P!emo zs#BYc*FTcA?Lf{6TU#-80m+>>8Cw+Ye`+CFZJv@Bthl$@KsSOepi-{+-_4lW+=ZfJ z=!9xEIO%()IV|Bh?r@-WuHwJ4XCq9~^{}pP77M~@?yj%FkZjbm(%zL9{2p%raqr3h zl^upF!NhmJ0lS{(8jVwt*4vu^-Lr`|(!RSb@w;+2Xn4J{2Xc#Bd1gbGGeq_c+V5s$ z1(fRVtSK_NC}=4sV9>`OeROHw?9u`n9aUM9$ZFYS(9CF@0UguCmT81df#D?`D z9xt1VBJGEdpFDbaUt?w;CwxpNGP^HcogH^x=C_0r)jT5Omn}?f-t`(=;Io1dqIs)r zDl71T@D~pnTvSs{zHmWk`E+}G8$TtErm1HhF>2!S(J;_xi=mUc9AVD5n7d}qeW!i@ z<3Idozx>_b`Ao>+qkB*8?C&sK4`;;wJblM#HLKBGQ8(v@dCe4*e2ivqc z`Ot&58G+fRgHR4KOEV}Mp1?rna>$AA+i(;AYf_}jbo!^JYcYT-mJCE;P{8M)e zjub=KcT=t=YQ?T$<&}&KW8=J7RFRpfk|w6c-J=sU#^QqyKKNh(U#T~x++@ z9bl*?Qy^^Av`R#XK&w}9KRSr>AIQy^wPS87H?VA6)QG;2==0GN=sjh;)Az8U01jz zXfRpTIQV0K7fOp=-?^5bf=u5@VsAaI$5JjPZm)Q2Lvdq-Im>GC3`UnI1QcprIwzJ% z@!d(IvZ#J}c_!C20&@f5g?ZQyj&XSqtM>2@W)vD{D`n&<0Y%S?Sc>6AI*<#Qbz7BZ zBlxMZkO8v>c|y7TR};=#qNfai)Ne;E&>v?k?B9R%;ZOg?uYdKkv;XNo{qO(F|JA*{ zC(pn6{G<0i*||8jCw52#D0=;DDN;j`whD-M$l?JBmAt{`odR!ZEPaV4;P#65BsNyg z^A_<+7S?rcbla2o7^+^yI|5thXL?ru?;L0gwO3vo-8uYk{+oaQ)w7*H`?L3bukP8? zukOA7#LhX}!9f7__Kp+B^TC?)yJG?kTRO6K@8GT-j%GYrbAPkYMx78U8;?`{#%BGB z83g++coR9>ux_{UwL@jm$dxkJGQwTQ;JKf-$dZCNhf76Gm{PV6en-GXy75N_jR^s? zX$v!rpc6)Jb74S<+XM|w-OAe0P}U%6^d!$U7N5@qTPjvbV_vhaLr{)37DBKE+vYq{ z!ZEbXoRm58Hdnus?_J4W-3*hZx0@6LVZj0RUCOy#kgp>La!=dz*KdZmTO6))N#=ef zmejql@=WCTbFr?%ymGTNzMIj}72h>q<*k`+saQ$f*wRnk^Z2{p{qEZ}utEUfM#Z=w zJLa*6`4-n}XAZR4qcte@9mVOk3f#2?>_RqP8KD@aS^ampUcGwhEp)ARaDcA~aRdBD z8}9Dj%LA)INhOwB1O*}dJmMpHX`>hF*Db3iZP!bKe?^5JKH<>!4m+8AH002M$Nkl`n_|BEgBxQkB5dFf`=czAL@hM^EbKm>bO6ES)nEPHpTB&$ z^RNH(z!}@V8TR1e;pbm`{l`E3@a41TdjC!_vLsA9e)q~T4GpArQjNpS_U{}ygXdn~ z{ZkPzjBgt|hiNDu^IS`{&_+U3tn1*s_<*AC#G@{Wr8iZvh~ChcEE9^@@syVMZZcr; zXT>Z&415f%WOxtc-itTltMwgTi&z%6R}CI1z5GfisxB$Z77@Sh1u$KEH#ghpUQM<- z`N*Naetc&YBq@c)WY^t*4xAX*hI$6#x-QU=_c&ab%PMt6Y>-=E-dzFjVJ<5x@SDhY zsove`S$nuH)V=h*&cBO!3-Mx<8#W}9@e^oL(Q{wr+rq)bMI$*eG+15B#xphjG^@C* z`NN$6R@{)^B@p$V^vVl{$dZG4etch^V1j4U+&&{FoQy~(w&vHDy*upV~3Ay%rq;{h-WCr+nVtWmPYLo zhOf2kn#@=XYd?8qQnVJn%_ECV!QOn>A z{A`LsaaT$j08~J$zfy@TxvjuXHh_S7$xV{Q3ZmC<_k1#CWycx8y$DUtigaB8WFey$ zaV#378$9upYvR%(i+<38{~2kkXR^lm9}CFR7Pxx96iK}FIO7V`k>k+8z!xF8Gvh=fJN|H4)KD?_kVB{Gu9PeU zOe?5_+u(jZ4)w8ay^#gI+f-B07LzCa@4F~^Wg!80z$bOhlcfa~65X};SP$sctCMfO zdH(XntDpVV)&Kdw`swdJ|D7Y$A3WUmX|j($Ieh-&EA6qR2bL7wIovbBb?$VpvpZHK zb$I-)v+E9@y!YU~k7M3(&;d81?Fi%BO%=i1Oo0Q?RD@=(HYg3@=( z#D~qYjF{H&;OZ%k0ZswOPNB*ta-wd9lxhK7^J??3xUB*ZP3?ikZ=(-N*W@QA;f9^v z2&j0=dzGFFPl%9_;E_)N~+0nnQVmHVGF{RvqHDyi$ zSy#%NX%WMmuLCC1 z$B6q!p^Ex_ zLEgo#b=@SH#4h2Vd8R^sjeuNP*WR}zvhuLtcRD}c_96$bScOpO0#L4lf5V}Tx;OTo zM_=Q|&ELCoW?gMdHHd{YNGZeG$}OE>J>gANshZZD;8;|5P$A$#e#%HTgmrQ2VTdM1 z4zNr{7C5IWakw(D5FWpY=w9DHJbZQb^62it4}SQQU;XMAUw;18|L)KJ6)kh}Y)y%@ zOWkLj!n)D)Wn*3mJ*gq+7#E$|K`u$|GU5W`;UI`!QF$~r)MwT|KP}0b9`W4 zYtHw5YTX8ty@Si0Q`WC#8x4rvf8axT4-O9PC2=OOeLe6YcJ4*Z%L+r-j50%HXp~}v zkd`o2?~tDiMUtlYI&xK$(oJjAg*KIIs>i4(YrDkH zaG0aTNn1IeKY#J``8R6x{d-T2k6#}g+;hbBrvBekjW&~H})VWoLk7yol%S?(rZz&wU2zU9B86d$5)J7xma zb{UKv@+w$2+Q^k&ya1GtX}rBR9vu6xTjV>H9gWq>-aH!X)qRw+&1~JLV$oNsvq@9s5Q4&a>d@iUS%isl>l?N@edpf6y<)ULyw~x{C6nvc zn|AQOIXl@oKf85trV%s7w0m{@fBZN9^4|ZCwma*x?6~g4p7YH)v$Ezw0U*Ial+;r9 z>Thm8c`ZM9dD&k5V0Tk$wWk+S58z)Z0jy=VW z9m9z;a$*4b9L##VHZqrkwG&uqL0FNYhFM*`_FQ@kRLAhd%T5oBX=ryF?#{=^{1bKK?X4fo0fnPurrv7|E+<)<#SFSRe{Bcj@{;eK;Rbdu`ug|E=96#U&;8A>USG_uOsK%()x+lx zo;-T!B}1fI{vt19IA*})Nk-(^+`YmM0%tg~zOrn`@_-(86v?6bo)N_MCf}$eoQdf} z84C~&v{gZnJn@I>m09bW=>c<|^=dS$!jysGYRYRY#ph11s##LAf^n{qm>~zA7u(aw zg?fLLajjXeh&}s!p`{Vkl68aC2UHu$D5^us1{C~r0Cbbn1(lVj_X~^2wxr1o<)(e9 z%|^oi%(>XUL$Y_3fcEzD4!$u+%rp5Xv3 zxMKW>FeWCQuY*K21_q9vS&MBxk&p>7f{Z1!BsH>kKnq#gSl@c~>`@!p!rx|@yy$hD zH@Wz-x7zvm^w`%Kg^44Nt}fShcILG15nS~$(H&;g++uO@Gn?4~vIX>`7qhJ2lfBLhZ zy!iCv7jq778y`7%)}Um0*^z9MkR8?Hk@yRv?g!P*ZU-dlrxQitoSaeT*2F(yx=r#?Fu(-4tY01Xpk+HJ63>gX?DgZ`gspB;5o?=c)aB6;xpwP9nH~^F+;p~H6$i^;4iGo)JlpmaT!+Y?yFpv z1zxER_v7iu!j>X%`Z$25nLqeN)$C!$N@^;|VP-<@ejTdH4>c-%3W09p(ZIGcXpiE> z;whzm3!hAIr6cK*PbaD&E&pW&X7gM(B~j;|b#aCvU~ zFxT0u%hl7(?e1-xf|0S{WWa@3K*HyHBbYRhYK(KM83bgKHAXgbwdGmeI#DRg?Pn(w zYmp=r-`BSR*1vdlvbg+uWp(T6lLt$qh4INd$FF*&)_@!C;Y6Vyv|BEPOw-jAiwhl> zx8o^P!7Txl=^LmBAdl!+$u=UAQf|1Mr)T-1gg(ayxt+Gi-Wx#?iZ3GbaDVl8|M2yz zxtAX~TWkIJVE@tH?v2yGqRdfB`|0roZs;43R%&4%fT3sR%kQao`>AA#Fe4`Sg=1=`{_Ri=SZMT~Rnl566LM#0k8 z>JxJ4ga`Ep=G2Tfga%c;a^@3af?|I`31$@)8Mx?qcPO=J9KVDqR;x%Xa7_*bD^xPU zsWLq3Dp(HvQ5s0_q?`C?TF(r)PPuTQU=e6J5g8^8D98P3Qd3KDMB50Ipq0?+ckvVr z$fO72fc;&Dirp{OkG^|tI3J|b_7E~9Oav%7zc+Kr28$*!>RcIf+Z|54pbE1*+0vgv zL*@vSX((&eoazD{mn|C8y;anq_?wROc71)T@iLK`ITA;0UM*bAUml-M%n+cWj)KMS zcxSrVcp~bgpo>16;ya@kX3dtDEmb>l!;7Pu%DQYChV$b1NXr{*s!fM!f-@uk^dO9; z4G1y8WjF!Od_ukuK4()xdVX3E6-?s^QKlTrk#x&}ML~{PU^P}&;3KicG-J*mkXlRO zgABQic~x&n7H5ix54LAK?b?qGUtG+9z$jD~gcA{Dpy$Kq#@6^vv*l+> zugqVMOc!X(@)J$wez5}R$=J-OkFjJ@#r}>kmUF&zd3M_5187986c8Bw@WGC{R3xyZ z9mJatSc_|3pN@=T!ET@I?p^rk#dz!uXy#(Hw4x?wRli&O@~dwS&M$ubAO7kW&mM2z z%%6_W#_%0M!G_479_=dXqPJ1cDW4kwS0V_#)qGrMOc2TJNB9^s)Q_4`(Y|Slr(GO? z)$Fs@!5IGgpbbN|V8}BWUm5)T@;`rN+W)Wq@&PiuQM0|VdF6|T=DsOLj&w=ClDgyL zK{1G$5}PIq@_4T?gxgno`XM0hrcp_~cf$lm{V< z^cN3n>XXr{3PjkMO8Fw==HQf=U?*d4(?JAmQo2lC70GX<*KcyB@`7B#tb8y_(HOxH z|D8~fLry=bJS~QT|6%?(sz{eyMO>83?bL%$0nZCu6r{4Leo96YqI-%ID|0RbX#mAh z(qYH4VSS=zcmOG-&pJ|GYGB{yW4v(4zO36NFN4mlD8N%l!1M{a`TA!01pRGWNip7UgZtljZ5sZbm2xfD0!9i=xzu-fJ1J{bl zut`^Yuemnuoe~-=hzvYYDgZ>tQ^l12=Tl|{*ENJ3E4)s=azZ6x8q!of+-{wzJ=dM}(_Uirc|>-XgG$K&&x zS8t9-|Mcbc_4A!gZ}wZ)I}IY4btv}Hn@k;q(g2y(+axz#uG0G|G7ILT6ba$Yg?iwY zooZ07_aE76eR4dpS$TDB!&^4UeEsI#(b4hme}DVwr>kBnJUKde@$8vNTptA5-f>d+ zSbv0j)4D}zj@j@PRXC~05g}e$+}c=qpAUjN5e7f4b=mXS9|x;$`zi|+)Wzat+E!Y+ z;Y%Dki@yZjsD=&G0Qd~;_Hg@FK_q6#5{ut0-EW-jG{Q8Utg@il2qV%@!)9^^Eo#0s zsA49qm*$#~cCIXna+3#gwxOaDRW?%x{)MO2InxGNDRb;;^3&P$7I>CKN!1I=oTtGi zrUbg35|M_K+|FFtVPZ~F;pFKs%#UTd7Y3jzjhi`CS9JoLe4XKbnNGO}VZ~l==9f9vq&HT5jn4dRTDQ4#OCl*7187UPhSExtA#<$}8rF@W zN+~nS%Zj&J7H&p7U#nck+_z4o=Sljn(kd@-o9V9H2w1&?SF$2>^qf z?>2fNvgc%_7&x=(5<_Q&`>bmwOrL!C+p>^aY!1)X*I3OcBuz~2zE(M%K^jJ#4{dH$ zEv8hmtb@)9l?$i=8koU2fh^ej+ymlFkXn?D>7ms+yTzL&9*3#L^gxz)Z20sdT-rC@ zFB<#jFdeIZ)%UDi`qW^)HNuk)g6eDeMiz5Eq(?RyUQW!`z|w;Tj@=kGoTWlT=&bh0 zgY7pb=eIkLUYs7j`{Jv&Ha!3PzxeT!2Rk-3Ilh`BOb+CMha5t{BCKpFs~Vf;LP^Rj zG!ueS^W-}MknE{xy+|52bBI?$F>3kn;MBL|H#c|Pe)IO-=fC}mZREf{dE(5>t7lIh zGq10{dbPK^@#!Zo_Z>E7vNiUf0-~ENkOx~wEUsUipD(YBd|}QM*J^zTA@;~OCmkgd zJCgkyS<9Wz2QKJb&8$SR3qUMUPLhmj4)(OHOj+FNaH@%IBir#oDB+nfK+Be)1sljh zdK!4zX&wa~JrM0^UKP_S*>Y=6Xo>>V)$<5~5ZLP&lxS3yDu7YT$doq3yobXyDLteS z0qI3{no_#_*jV|}_9_sn-;@=yr-l0BW>Td0ZQ|60!Uj{LlhS|`sLH`8mMaw zV7gfk>gkXrKS`_50B3j%kvm{TQ6V(TUyug?<>#uVUo zGGlTVIzB)M!<8vA2H-_er*{uFEnIAhxH8okwr4h6b@QfG8wiVF4cT<86$5CpV15Q8 zOAUQiHfvC^db}@9E>oAP4kN8*T<`5YWHq+7HXLZn#W)-LlJ+vc%&Lp?48754hLNC+ zTW!X2V&K~Hc0g5M7b^w|E5EpC#tcPq08k%o3}*^)wS_10G{Q~zEH0ZcyDiCLM~kB) ziG$JE#4F^HK&uQ8)SCtymZts5P+Q$NhN^a|bQTygDYLX}SuhQ;{2BlQA~J5N1&v_C zN>&p|gJtYGExMj(L>upc>wE5>6)P(tH$Dr9+KWpk@2=(D*h~utc;m{#E1?bHLzAW^ z58-|4t=~wa93C%ErCkv^S{Q1dzjd}(k&$2+Iv_e2DjGDrBK6UiP3 zRO^)-EMYRhq|N$UnXw^#=ISkpJv;onp=5>3!5iyLY8Zb!B>Wur+c7LfCK!LIh- z*d0*Z$g)?qetEfOamt}@)>4NYkhrvRwLY?Y+|+`N;MO&- z=bdnxmJ;LvG}bGHf>bdbOSA@KOw+h=25n9-`KT6)_+1wyRgK=LNVJ&XvE7)L0)|t0 z7|k0*k^3Pi=y6~3-AQds-~m+Q04f@_y4qXf%^N!^l7mN#;w#sur}(6|f&i9R3rq0= z7~!)c_{fBYn^bshox?hB@yYC1N=WCuUTiuHk)gdZA+DKbLIBOl@5 zzuzl%wd@vJaM+c{#hN@ecKSWc7E!ZiAC@-P>Sz_19Zxqoo+B)Urp>pv#>pAa5D$dhualeB3ih+ zh$M4c1U#r&UKPk7jA^UI@pJ|rFT8m1^8LHR-~9U5Uw)O|{n^un?d@&o*%)mjpy{g~ z?{99HI#_t~_Wg$K%Z;Dh$Y*RaYqdCbNU=U@IkUaAfRWx@wfXPr;_NJ2Q`T1Lem<(a zkdqiAZqp+Xg}L2yJq%0j8_fJ;roAe(k(D2+*eH!~6H3f!F17$pr)z^w+NW!ABzzyV z0!KkaJY`K2k03KQ0TRd^sCU#T#Nc2SeCe)I=HIcC3}TcrhiXP%tBxJ3nv^K}8b?8U z&lyZ{fMet!gFl}7r+jspnad&>@{K)8k;+io8#|Eu4mJ8I`*%D4AO7lv#N@A+odY4N z-o`tP39U^(njvn07Bh5@JWo5oLlEbArO^UJKLg_~Kv7xu&GfqmLoAKH4BQ7#()c>5 zunqBY3T8v|6T7U=;yDQi-timTDvK;m}duptxT-S2D|AC~w9)$D-Kv!U9%V4>TsJ8G_6fPmx3ydOKhz5(K@6 zlyt#iH=}D6=&C;d?Wp0S>x<(@yQ7bv?f&AYANg|7^4!Jhf{(ZRXr$Slv7x#yw%bL3 z_?~=ZlosnH1ppJ!U-N^|O|zQo1<8^^c#nJ7YOB_Kj?i$H?UD~;_;gLS;yJnKzy9*; zD1q~XMm}-qJlIi|MlFPna)iBz;ge^*Duqe=5X5H^sDQxs|@L@EpwSrK>#+;Xh<^>ua$XLzKpG;pLVNaZsh2uR^s zk)fHOPDQM`YnG?WOsO(0eF{4dmGPf=^6LU-bqk6$0s%U0oU=rcH2<=6(Pu3BcAnri z%RK{B5h|f%49C4tHkTTNZ{Ib|18`I-TYX=Cb0dG_nQNux8rz@ylEU;l&F?`=XL_wx zCwHKS3h8AcQ$zBE(rFNvk2y&Clts-(DOpXxnDhc64>W-}#3DRm2g4I?mvgy;=JmNF z5LLlv%|ws-Bdu#QXdC>LTArdy-6IDP%f1rDqLG-D8r8MveTR*#@@FcujD-e9_B16V z6g|08>gHW1r+7#%I00Wc*KgfT`R2JcFFnXZKRr>)E|aB6*NG$+f&0+Ee<78 z#!ow|kB-H8Kt-Bj6_LTg<=(#M4ab!eKnm6t7=3EOmnuw1XV&@bd~V|S>V;9h4VZIb zH%e~@KI4VP9M(r6V{XJs0`uT>d+A&0jVhzl*+N$nUB;0^3&orZkrP*zCkxAmn;W(Su(04|){4ROK1j?adl^;D#3rRs zOFFI03FNpkia`a+?y>1*SvkA3@17Y6%bLDYfZE7N;WIBWgvbKbTuwpuU?^?qn^QbaT}B=x8U*8$ zn98VeKBg6zlnYsg45=}r1}T}qkXyj$QJ89!&SvX1XZm3 zHn`Fe9XrqT?!l~R@o^{^AjNR(DF-0UKp7g9`GVMyD^@Cfrmm1d(bO`1&g+@8;O2vY z8Q0brS;Mprq-^jCZwDoo5-kz(G+NjpRm(Khts(Uf{>+p}t|^$i0&TlhEqGk;W5v`i z6AhEjF4S{;8Gsc6Lt2^UbTS#s^Fg%q?4?b2pRuH6C&r9bi|ZXDFTQ+pdhBq3trt&r9&B!JtZ(@2;QQmr*4DOZ9}A!)jd)w?L&Nn= ze`pfgv`d{T5QECfLUz|>wGaHzZEiF#ZoPlM@AF^Cdj0P0Z-48X@R@sk_`pik{Kndv z`6$@gOTZk2cr{gT#JZw6L*^`pMi@&53SOy8K&!k(eJoXKlv-*ShB4gRJEK#tf}c$u zK6D&XKZ%V8)!}M%BfdcC zZHWiku!xsMe^`8XG2@A+Aw;P;D~yr!1WhLFtL`fOgiKvYys-!^wz8eJ%3meo3IuM2 z=&DGIosz&M&P=rBZHZDbqNwh6!R!W0madnwuMC7ZjcM{eq|@QPA$? z)P~aeMcb6j%Ek>z@?-z!Y^z zjJM~B0rGOz**vgcjG;EKlE6H3dP`E(46Af$j#i@;F*UsT4DL$auF7>hMmwY zYZzmUnR=_dBLH1Syw+pNVx+RPlJpd);O|b)vZ$P`mZ4W0_wq}+x@>)0@P&xooB6Nb zz46_ICr_SIANw2lswOEQ=`_0xouQmv2O%vhRW(MQwVD=OsHhM;igE%8iX}=<+5c%T z9E9bS(b55)r{j$=A|D1W0%mcqZ+&;&gc9CJl$ZP>+HR0Z4&;Ip=6aTRhX5$Kmr_hq zr@!(Qj<^xM8Ia*5d%JiPYu%u_#bp(umObDJYf(yFv?p8Um!k5C$CE9+0TIK*p_-9r zqtcngQCtCcs!>hR2BuUuiHQt2R&LZ=bt!5Cr)U%Tw}csxL zu9sAjwr^?ucFCu4EmK^Sze8-)70B{Ircl4{SDwbF5x9@r{p@$-+$VBIW^wT@PXx}z ziPQr&UceBH-++~U;5!orptmSw(_)IqeykZK&%Npb3SX-|lv`&Ov0j>Ni%H;G7{$t( zt(Iqdn4NIG)qU;w)6Ja+dFj}AE_m^5N^`r%p)v`nD#9$(Y;rCIY(8MboHC3?_=g`brD>K@W%hMXSo|tK}lSqiniT>X-5Xz{pCZTjhyUs=xstos&>J z#op3E*Kj1n``7KgtECf*1(t^9$!tS<-UtAVuBNyWqjpXUE4Z>E96f0CGc; z`2PuT(7qqdqNuA#8>`MTWOm~yBs9qTA*xK$rAMwEkwU1j4Z`>Nc6*(5su&GJy||%X z1)}5k4kZ3dv%*^H61$KFyo$)M(Kx2E3qX*!=OAUt@g@?hH)bX0mjD_#rM(v zqwy+|%2MO^<%WP{UbB-ML~0&6`MR#5c#39f)vd};+rZ>}@VC^66JQ>5Owvv{Xf#)~AEe2XD;rP7aHJVS!-S-g7l`fbAb;gPrX z=A0@2=+XS+#}64_zU1-oAy0O9XXEz9Yb=?|M_WM)GuzUHIx2#{{_!=jN{4L*H_KsR zLDw!Of?;h(u!^H8h04P0$-_s-$EGOWnu<9&AFrPH2E4Oq&+~cayc}aXBj3C*U0}1< z#p%h~hUtPRK&lS!iooJxl7>o$R!~!3`jUt_TC3qyxfrmt6fDpAjg&6DPgf+17J%uY zHaiK_6|ImGC{?^bq_2qD%b)TUX7sv)b0>c5TC6Y!8{astd9LxJfzB)*OzTonqNGtK zPSlcmvzl6T#5hQ4CZ|k$vZYKUPyt3+SP5AXgWup7P9Uu2IEpJt>cq;K+?6Y-!8P>z z69*TBfzZy^S!eM-B@(Ke=fyklzqfQ{``#yiT4K=>VKx^s9w7YUNRu=u)D$XpAEKDR z{b`FwLCQiD%FVdJRt8!*6-t$`;+E2}gM;_k&KIvIGi6#Lp*q3DQByRJ;s8m_stH9E zVpU2mNl{G=q9w+3Xfw44kSc<~_j7DY!QC&?WF7jklHYgxw5l{kK#oEy67|i#`6Ra9 zNNh1-NqiGJoW46{g6e>eJkt<>9ZyTIILjLAP|d^U0sAdV`-^4(V@%A~EuZ=DUp4Zm zAl%y_0<2UqJrrnD;2aAF6u8TB6pNB!Te~001jMV`L4v(`nr*gjTToYr~9@} zapQn0Mh;y;G&3!?)?GAb3Y4)@c59hwbt)s3z+TKRwTutT!^~ArCMm5})J*r{-rnx3 z*ZW7OmyfpA?G(hHwbA;!eXoO!*EUynd}3&G(^o>w8|!~~_AwCUw5E+W$ZJ-S( z{HyZ+cV>#)6D}+6N2SdcKW?Mi5l;O~FY*X!@zc;L#3s_YaK!Ck7ORJBrI#Kyr%}sn zuo@fMJv_N)&&|TpnG*~7Z`N32H*;PAPz^A}gJy*irAMce9CRDcD+kcT)^@DouwxnmJfpGjUkVbuCg42> zd(*UC^PcBdw@y%Zes9_zz_PI`)!E3LX#;g;z5;{tX}#1?9N=!SXz?*i`D0;YnKkB| zRH-f%2pn7*01YCs#qQ+zET_blG4idpWSd`D-`r#Vyrp-2u0GE0#_HC_=Jv(e(KpAF z^AkU}M|^mPS*_a)P=_2KrobZA$K)NUsJt)2)@H1g`IYUs(`IW8HW~Kp23uQMA8jZj ze&I-MuX2y($7d6p#Ee&t&zw&n}orE>b#>x9dA#T!jw=xD4 z6(sH;iAIIq!a=}!q9KAZvhBE(ZAsZopmjD^6w#Ad9RecPo5k!y413D8CA^z!wWA{9 z&UD0}btzlbAzPaa@ufr2RJNW>8}hw$2VLL;9noG>#Z+6oA_TtrUDHZyzzRb5C9nxJ z1#*dor*L_selrS77gr~BrfoZ5(hdbfzV<{4iD-e4a(Q79o{(#GoYCW96Dg0U5fE^C zg8&K+ZQ}!?LJ>Q?x%X>o{@z`pjc`}5mMU1)cay8xlMjlxHTp>=m=te|2~9^BBptTR zL8>N*e;0@pXSFt6@o5x^>cp@zBIf4Bb;SVUAy)LFa8(-bLFl?<6PGR`n;^Xa1Ol+o zl_-vJvcc};=r|lqubtt2#OeRlwF5!o$x^J86G%5)sk-u}S?oq>hHXzilgGrSbPP#U zP>`UFnO2h$gc3?UbX&A-zsO#vc)BJ4| z9h4Q(H@7SL32WBeq>SVRB&6P%pmVDMx-uA?aCI?x#>fuaapIKmu}2slqhXnAa^w4q zIr4CB*}Jt)Q>|&c;%Ds^06CQ)E7JktJnn@laUgCi1F;pUD^qJlV#Jh2j;i**6!8ER z{uEo^ShddPkrB01E0ty394d=WLk%*barsi`;=I}62alf5T{{%_`116G1Z{X*TVHY7 zp#28kM`i&es+y79^4Uo_DseJU6QalmdIlObl5FI(j#^$6IOTRR6rOPw4<-T2my9pkq9P{PT{ z_+NhgkB^_fc=-6SCx7vapYQBFu#4;OfAx2-UVWtk9nbvy;g3K2gsYjlOzZkISdJyk z9&OD-?J-j4jxI>AH{`@#&mK#`VWIFPL20EksQ_0Ap@sJjb} z^ixU6XN=Gr*zU3nF;#pgEeRlhkRHP8NuGAB@&I3Vf6^r!>;hf{$8{TRqmT_<+BYnh29Z%pM;+HhExeyK(rnNnmvdx9pl zf)nMf!BlXJ0}|8*CKLh$m0HS`5s5FLiA9WsMZm{!gdFie`E~%mE$p|GbLWwuI#*@; zS`P;1i1T`qsRHU{Vn&c|t}Wu(&tNUe`$nQ);S+vW-ltt~VRIY>GEU-W8?i*dGxO9ibGGJUbhwo{~k? zj-0gE+6R@k1%O^KaDr1^ zu=AIH{)?ae?8nY<`26$Ve*VRqfBgF|R#vS&CX7~AoRZ|dWNfQ$%}Bcz7s{macX81F zp05mYEm>FCxQjBOD_oC0NC~S;puNmhD59=@(~Ae;3f02Fu1A#6`+_T8Wh}L@2{!F> zQ70Gf!GZcPchTO6v)NTof(VIA6RBnufa1gu9Z5sg$>j{Z5@YJ7Fey>l;M2%0J=}Y& zlu?g`Oip-#03#Z#Y(hj?v5>5EG@!-;NetLDynwB1vR3}&)CB}W0DKs}H!+zSuMe|# zU3nM`OD6`!_<{W20Wc&K&H-{i!w0wDdNg=&!Pj4=kiy;!wFnP1aRP7`rHNV(wisSB z`vG9AOy=EeVahcyimM#ju&6?$S3xU^LE$M?J0pma@vK{lajGU3OcjkxQX2u2LPk_+ z^I7~g+!MmpWNW?^4?Rsv6>lJ&hrbb^(es29PDdULT+0JVI#4n|H@;r>>LzEaS4@Gi zydjq@!*&H_oRm?y>|QHy)M1YqnrUV4Tc%!jOhpB`D^EqGzd^N(HM#=atstWIkj$dn znRLCBCF!bYYG<3qh$-TlvbWsF0%G#O4lAw{2r{M7VN8acsURDk?)Q`-A^r%Fb7Mns z*?r|R9KP_zEfC06c zdSVn16@42*G&6yfyS+X;IapuceEewl?Ckaap`2eG9vyKhpFVqL(t=Zu)U}n-ryqa3 zwXt@1c>Lzw!PR8Kt({wUrh+%ZZ8~J49sQ+Ylz_u&6{{S7(qe*Ko{&hriGU|G&8P69 zN#S>hbE!}}#X@Y|1yy+GwzPD}lM-c^Qx6T)ec4*h-(%p^2^SZwH@P6tc@wWUG9;@x zpx9Rbt1fCBNrmU}94S~7hy}0XqAIT@7g-jPI+v+B%}RIUd(HNB>p`Fq5rj4& z+kn%g6(@(Pd-|xJH-*6`l}hTX+YY^YK4iW3fitV?hme^8lCv{&4%N-n5=k|`#hCiPs6b(dc$v0s`Zv>OIuG$)a*8=mwL&{r?XTaODO32>pdj= zFQZ(Us~KhIfNK070#@Ydlp#{_O^!ejqL9wm&hWqbD}z)>*1S}q7JFJsTX&k)Ao0>` zFd}*Z7Tq?1R6I4N1|ilHTUdtL{SjFwkG)C(D{FAjJd1PDMyozKMWNbkFe%HSd$GsW z=ODDi+8?Q)7J4~3JKFSlZLedg4u8NLW;WQh9Dva10h=!&mhg$PEJkcc1gef0fcis2 z@F;{ltp0*wVO|F^N`l3Td{v_(K~fR*GQ#*(RxghR*@q}KdX;~3-74P$o90&Dyf=gG z;{*)E@4oo0ipODwA_hhNQWU3pbdV}F2@ftxy#I;-sfv^ds^40Xd zHmS&-o-(qTuF63!^@@77$S>cm)n;OoONeD&jbC)+Abxa6!v~yYEww9^4`0%yVVy@| zpl3{0Hc+_6yQqnf^nXGWwOJYcBab>51kCKsN;A!08KJbbxHw+fPusvmDs^pahYq@u zR#6&LRht_HPb!AU7EnrgzUb^2mtezm*}iK6!=ZqtOQq^AeVuS^=X`y%ct`c_CKz80sC1D6$LY7MrQ2l+)GZES#{+@ZH&3KfAbET3Pk=q?5BT3rPE4fAh`z zcW)APsSXL( zYKuB^3^c3?)PPy|SQW;7D2Jh08xurt+Vw0)2tw`T~w*>G}!=f&NVnp&Ct7P;IMYn?W5y}$nuIX@->m&!;e%dfWc>esk z6hO2VVK!qB`BqS58mdE`I&s=qX@8K60Vr(h0yb2VAjXO*UQH&hmPAV))_ffgss?0q z03(Cb3Q}+(*Qxz=D;~H7bCylydpkw^kJW1rfa9I1QM-9EP+i;JqDCyNI4zV73UiC2 z*Y6H5=e<0Y17flejah0V2-j3A8nI|KFiDIBoAy`;3=3;aE*4|6pqh$fXM=ao8~7$z)~WL1 zmE%`^RCux0rf#m-CTl>)M~AoL6Z;Cz$0yh83m-jue0=c6n@cESH`wVIm700w{DK_Z zW*(Aw*^suj;ZZRvk@A$wnMypHAIPBk;bA%uU#B5JrBKYQUzb(71Viy4vx<+PFdXSKyY&L^Ofz*jn{AA zynFX<|J_>(;Sev!%0w%(@L1B0IVyXW%7a>TJ4#0HS;WGXe_DMgiLhnicqE1|-A|`) zOZO6r^pjEC*h(p40UHi-aI-9l&*1y7NvkF6Z>mCS4g!&yh9iT1J2M#Z5)uY?1g;4Z z%8^Y?5nPZlKLm2KR{qplAtDM^NwA2zk|X^ypi+A>C$((LYzsx~g+wUBp&Y4BNlqGA zf8uf_1VGZG%|>xqgsr3xF4LUfN}l2Pt<<^!(C-vffXBd=nfPwzKaf&z?&@({SP!P^ zdkba*U|Co>F*njK3SBcEuALaYhem6sc@3}$1i!=K(SC4F6HX9uww6`_vN%g5Q4!GW zycg{E24623ha+}2AtZOzQwpk75{1Am2Eh?1OKY&QloDkq4VD;lR8)AHB2~~uB9Mx8 zio1q2K=i0w@91F*qOVSqvKeSl^yC3w4<7t|2Buyg;Og3q;6Z9xh}WN1ru%KrB+Wj$ z%-}@*hcHE}NUpSuH7hWGp)|k($!-|W^Q7!&;Kp~U=Fv(Y69#|hKPaYZMW@_Qqp<|O zceCn#t=29a0kfhVqyQXr71fQhV3!l-OF1;h9zY~6%&o8dTCkq?qJgJo+1W5>*f_Po zLBN}<=G|}CcQ$v|<|ju-`)}TV`fU5kzSs0U8lK#7^au$h@s1;e>ItgnC|?(ajWLgqW|g8!U6v& zm@jL@GnGE*u$VA3tyu1K$6AJ5nJeI&M@&>6ucLc_mriExo;%R>hT#F)9W(w%>wqoB zvdo-O46Is94ASdSroTd$%iPV9SpvmW$bSV|cWoPx{Sn>s7dV%wOv(iRDqPf5JWpq# zD##t=1dyF(Qs!Jzcano4pTVD%O)38igb2iQo8BT<(ERvccD1O*@O=LQ*vdFmlic4E z2F@2t4R70s6fE$oitj0e*~LL3NEe^lvqLq@(d-E7mU4b^&gn5yOFMV$+w2JS>CBpJ zN;ufQNkCt6h7jm4oleHoZgphwAiH-=j9;)@R~I>Mw$whQrvV@G8fk|zQejE84S0#9 zM5gxz7wXGcm@E#DFkDFOB<&ojMZ^4|&B*{{`dP1L4$%Efxr%O9Bm?54fe^74{;@Ml z=18Y=DEbvw`FPL}W z13RH0xr%CO*pk+jISIq9h%VhWY1a!mS_8Y5MFg|+j9{iSgXKWRAohY)D8iT#P8}|q z+~UGT?32Jgl9N45Fy>gsdf}J-j7(vc?_f|~4i?#llubnd#zJtAP;QKxl8biA;e<#AV9{-NP99GZh?OP@C+ljT+nw zDRq+bWb z=;bFLJ$ShL_Wk?A<0Ef0=+<+P&HP$ABB){O)y<-lbgIg}7B-P+E%Bv+qlcd8c(AD$ zB4ft>NLDU3CiI5#TsnS_5ZPkKGZ;_b-?zR|Pe)TGjOH9ie40%>UcQG$UK$a_PX>&u zflX?x4H8KVGU2$RDP5=~AGMqtS49dtzlqQP$3K6OX9X_$ms5dZQVppxiW3zoLm``Q zcE3E{ASgtD`7O8_?I5QlRWRzBT0f7{j2j6L6=?&J$TZl28f4U#>i{k0BOOR?LC95H zj@%X;kb-4gkAbj;Uy4;ZqhT6PEKCG9un%|D#R}ICPcR|WSLw|g0Wn&&askB9qToX$G}g?FDM~^XTfC(3jlOL zi@!4JPwj~WnRtXTyLp%$GmIC@m9$~M@WAi_f}ohfVo|3Db?noJD^Wa=Q9Rlm4b5l0 z2|DJr!tk^v{(?oQ&&f+kuO6}CR9=FNKapKj{(K(B0svdaRDo-^Q=IkN<*OUpY}S3n z*ZF!2%#Ypj2J=f7w@VgQ97LTxj!PED=dLG5ljHY$n;S;%YIb7I!O8XU#3bnY+Qt_D z5#M;SkwF~m^ek+ij;yJX4qJHIYQ?lKF>dA(%1X!RM~AY8iBkKekn8kPdI&6hES#*2 zu)8GZ;9#~lb9?C_HhhR+;d(>QIDd6^{Ql(l-NxF=tWXK_q+0~1~ z!Fd{Lt~^@p5m2Vc(H(85HYI17rNShyz7s5W3-uD67v*q~;5n zGr4kl%HpS|uht~3`DrI|Kvc-i2HmzwBC&*F@BoX*If3H2;v^a(cwDIJrM4}sOkJ~- z4+5%L;-t5gfK8$ILAJYFZcB1DQ<7Ys+SKBd-SG^(TpP#0ypPNsupu^7?T)~@P=N?p z301yg-?{|%u(AT~<5Jo$cE*zh2dWrCA#6l1QD^+Ye$L+H|&%apfm&9(8a>O-zj=y;?|XOkKnrni+h9IF&dn)UKXWr*x%)QG3r%2&TG zsUSL9rn(Zv5J?Bq>Vz%@h|ssnWTzR66y~@~SFEfyCZ*Rpw2QnD>}4b`q^9+@{4$-S zh1edF)kLqQ7N-LzWDeA^95Egpg1$XY!844`I2|;Kn#>xz$EI0Ym5Zz9+}J6fuFM|$ zRMs5jwBuL0s@r)-QF}Ah`#k8(Css153uv$s!f<0Ta!J2sa^h-iWbfGQwUyPY$&Gip zP_VVOb~QQP+}#*0T%R1ixjsMq=!tRkqph8#k3QNMtxEi6X<_4&7eD^{)sYRj7gsq$ zYvtV6Z?i0EurZLUl6cfjd*p?#AdzEl`{e)77}X6vq0U(Zc+(a((wT&qGzJ7D`{>Uub(cAss{$~H#C(pd0 zp-YGjk5Axj=Ge%*IlbVJvk}YgqYRcYeB6V1Uf)_H+4Mu$QvP#2^QK&j6?eEDfIr0R};jNT=t}si#65!g84mRrOrDHhs?8| z^wn`KI{{32yFy8w+Sg9Zb(-NWGJ`gYs8qbKNeo8AUDst`D4CIUl)Z_l48$-GZADV3 zXIRQolq|b;HR7qfGb9J&-ZIM5se;K-H3p4}2Mc202tmMKbR?X|$4D#9AWV0fZ;QW2WC4KlyFL$~;9WLn%5H!nRpM(cVPvn4f}gKIEDe5(li$%YbQX<1Su)FO)?5MG6k z<<*uZrY(m;gR``8d52I9Q7aC^Jd5Ju0sy6Ed~Z79FM3zXmPeI)3V@?dXf-sDlw5`B zOaPteDy7Rbi#y(lGkj9EZVF68qAt1X{IhFkC>OOK%`yxSW;y?8gc@|7iZmZ%R_+Q8 zS}`O9nWCkUZ`=la?#kSYVPyf6zcRZCwwM+XX0|5-D=B3lh=F&NF^)`{Zu_kSVcSS} zulC?8lDQv2*%3AYZ=)Yd(#1Ykl};d8T?s# zRWx)$!GDTF$73x!OzCI{JpEv0j)0fT0}SFgZ-F8z0f}<(DSphFf84`{T=@o&{R58B zPb+iT;JDHlAba?i48_dldRiT=%+t<`S8Kk^0F^% z&yP9^-yi(!r!Uu5H+0XemCwdxP|ha!Ktv7ATba;| zw!V6SDh)L(O^SH7%0U~_QBnS7=C%4t3w7g3mz&jKwYY_K&3RqZ5Qf|f&0*L3k=8vm zsmS^1v{q@Ve0Fkdjn`TND-V+t~>2FW?t9jQW zy>)p>G{|xYalweFOV3LcHH$QVl;>)KB;qUW!aQVn5c`;?F1ZsB74CTn7_%}$y5oH) z&jfO22GZ8(h(a;hokwZP({T~E6;H|#Vq{Zv8pQjyubxhyR1IYwlF~&Woagnwknf=) z@=E6;KvKU4s$Ht8u}xQCLHC0&O}Wc6%Ycuttqk|bnBh5%6uT9=m#08X(`WGhSR~D_ z6%8YcvsFdrgUk}z9=mGA%Dk9Rpz=Ps*}zP(bK|h7?%}+!8vW3q1IE;noe2KlF07cl z8JIjEJC(+i3$d*oGEwtA?3zCQzTYx61Effzf{asqKP-bfS^DbPEZ&Um>nuHym@)!E zsVLO7rV!Bkg0r9upcUbeNK1K=nej|o6nRQ&QiMA^D-Y$4Qsy}J^17YTmy^@Cr>F16r{0A!$+-HnpMCn%&sfjplcV?J^NIa@D+{A%kDmUI z|HJ?E>Xlvn@@)R**C!u;GH3AX%&__F{czBW*w-FroCkDzcDlB)y1uzVCC<(q zjQi#ypJBcpEw5~Dt^H)@6I*WgpX~7N54|yVdh-75;nx?35B3&K$@mz=NJaB2CTAVR z>&)O~JrgNn8nYWQMoC<{rDw>9*6;}(gwF-PVm;lVF;A+1XN;=bVjUX^RK5TmC>eI+ zwl?QB*W9H8RzP-OT77Ud`WoM^{5+|c)Z7!-x@Vls&fD2d$D6sXLF?Or;m~aQQD^Sw z;u*^b1?_&p6e%R`Ly}<%J9yrEqT;iYNO2_a3T0y@i8p()d(%IV>E!E}+r-zE7wPip*5ot@b=S--q?;2|HetwBOx_b+s)`ooa$2(ns01X`?loCB%$o|C+&BTLKQBO{dH!%td(l>GUYLeBez7z7+O4j^CWC@xCFsP=@MXxzIkFhC zc$2lM=na%@s-5>n)8zE*`ewYcv2=X+`rVs%Ha7j}ldUIDwm*9D=;`BK+Z8S^#y(8$ z;Is4b8n^_^p;NC1|KPWj%v}Bd{crs()^Y+WmE9at)m(KPN z54@(Zx4Vl$Cuhe1F0Cwn{POXSK7D3o?CUpQfA!_7H}6i)&#uoWCud71J_fuxa)h}z z8Z7cHTM;F7T7x~9QJpUw>qDZkhM~=*E*kA+kqN~N3LZAm`K=IlIHB2<)U;}3V0rqn z29)a%Ph+G(R0Zdv&5A@-;s=DK4Xz|0UVN@}k7yWvg?Da1^uKa!xRKB0}jsCtLV?S{eM5I?QehMrlrBLAQE{{Qhu|ZK!>gr%88Pky-MG5h?-? ztdVbc77fz+tLhK4XKp(n{Z=;5<;H5QLCWR%_{Ju{R0(hf6k8kFCyT~R7ugDsHcHIO zd!bQFkuR95&f~lu1pZVQUHMcJL9dzG>8_NZu?#X5iqEs>-y4bY6jx@9Nn1^m86?tt zXn-{6&MI{+vtsvZOy*7>o`#wAA8D}+^N!1^wH8hyeJ-q{^9xR_Qu%ZTR4ZHf4^Bi9 z9k24S9+i+toh=ZL^NQ-xYkgaooC=A9TI&0WG)&1%O$bJdbkj_Yb+)93R{1?E*Q)yTH8a5J(C%_joJC$~PJJ%9Gmv$>C6KK$&n z7cZVYG%fGtsFTw(Bk1kTt%c>$<@xQa-+ld`{`dd3yFK^f*)DxBJMrg#_TsCret&Xw zVpzPe8ufksWArRi$%BddJ^CH8s)pfW<{(kj!%wHZx+|i z^Le2q1~-DrTTYiwFtB8GdU~>v*@c|Is~u$C8KbWqQMfGS@F3O}-yT2Qe)8x6{rJ_d z{_*(uWdGp$c>jE|e7?4}=#9DgMP~po;>>K^7VAFwz zLH@e+go;p=ex)Q|-3%UMMY^;+Pq@onY!5u4IWy>9w)&~GV5MOPs|0UCYDWWr!IMq$ zG*qU$+iCLj7q46Fl^}DeLh_SI5f*`!EG5R~_U%x24h0CR<;p`M>o$WQaV%KO(5{mX94#AQMLs3}sP4 zOLKE;2U1vrLVsRIr+F+vM6{TWJ-#n38_ZmOg2(?4!Llr#lsIzvqcwbQ9*M~ANBAH# z94VcMtg@{ks7zz_b=o6=+rU}B)}eG;ez($HfpQf< zj(V4<$`EBYHjuLFL9iFUjteHB{ zJ6PcI(Z%=s`=)U>H`cOnl>;uEXv1DX=<57(OlkkaU;l;4+*iN<=I#Fe`Kd4FULU?Y zIXypl_R+e1{CWA+*z+HDww+d~tuNu`H7afdQ* zZC6Rn51AZFlTDcIuM)R`z0&#;{)nY=dRvN{TYm5~)TyW7g!vh8b?B!9j479iwo@8t z02TE7Bob`Wu?C5Yv@2Sh9fd@j7FBslExJ_3J*7ICNRD9=R8`l3c=2+9^GKLt2m2wz zwEljshHy_^t&^=bg@6=A`kFQ(EKAaneelV9HiG(`aXKf=|Izs^fjDlproB zHM@3VLCOdUJ=mh|Fq6XR#%^!V9O0-`txX4mJDdZp>?6#jW_xGG#Bo8P=RPA;yWgSA zn)-gL6cd)3qSFISd0%@G{N`$T&1f}%aLgBW>|*pWfwUW^LS;=zo3Rt1UNBR8UYYmd z0iS=#IasTXzGi>DamXgXp?NF_|ExhH_qbLpWa@5^6{hH zt%6f z_ki0sK0VUPj}8yEHs%fw#~T}4Z{NK7=%t+R;PB|lLz9y8i>q0zD{i-P;){UDnFyNm z5|zrQt&0&KJY}bO0<@WXtU2@LR$fqO^a}bIN>#menzqT>5-Ix!$vbuVHF}66Shx*NLe*2ey{ujC# zv#gBj=H~jw#ws8Z7`SCyn%73hNBgUz#UKCZ<;#y>j8D#WPPU7gYI}QNhuE33g=w6e z%d4vkiz6-2ys*#u@MaxIu7Q&tCW46xI`@1fkC1!_Bd_3%T_5c3<((fMcNj7lEA-hH zj5+Yf1Xm$1jnoJXh*L#uV>ibGA zJg8-@$0^ljSEPf?aY^nul1WKbU0U&JY)HtDva`D>BxPo4L!Llk!Qu;e8XqO#LQ0Bz zD%7JITqU78sI=@qAt)wy^Vgg502(9tOmhxhrkE0LR&KA?kjXfDljNb}iquXcs}f$W z5x%54P(xRgS9+|9*=dyh2DjJF#KQx48}p=VWf*QQq9vKD9Pn(4+!SF$;s-yKr|B(H zxhFb+455NgOSy~4S(!fo@Nc~hyU8P$D4g~TqESP2GU;L1&s5C22)S~lzsPSmmC{pk zgGut0E#N6&vmhlSGs4C5tU@t`fsTh-X3L$8$8UQqQ)&AZjDjJ<;{3ziol8z!b_03+ z{@fxvbIOkDg!r!JNQ|vleVs+OnL~J1PLKC3Zt}aOudglaJluM?_h4&t<+D$pIXi#k zQ0^7d9M9j3d6V_ME4vF$%2!(U5~-f=zy06;aB@7yo^Iru-S&Z+Dm>RQFR#pRZY`dj zT^kOYYV?Ai25AMA3e{kjyzcs>aI8WYH04wnRKXL!o!;?Tx2^`y8KTVC=4dtZJh#i%mL2q!#i;qkqqFm~F`H|@*g0SL zjB|T>IGJ2}4`qc_Hg%g-Xbm&7dFP9_XUsUi@7za;yvP$IA3S(~3(i$2OUNM!Ly%t$ z^J;B#Qyg;?4;W?Y*l2bZGlkAPV;9Js68wwd7_Y3u^`#3EncL{C)FN$2t=rc56zI$q z>D#!4LVIu-mX~3m7ivi5NI7{K-`vF8jGWe zBD?d4?9>=A5@(*8{HZb_&$6)5=+w0_=xa)TJ1Bvx(5}_SEIQ?-OB=&4_%%1UpT?gS z4h=s!XDBl?F(0L~v2Y<8JIo;Phg*|dR)rXu#Z#ttdBuN;%ck!&Y)OsOJ9%;~w5mXu zU5uY4dEQue=8n4-$@T_O?qSRbXNevqr$PlO`Fk2F)kNX`)BqZrB4lnX)-4L!{KvU4 zt&lMb1~NCb260Kn-duxGAsgh-9>oByuV7)@eU#*as0`J+CMO}}&{_)&SpdnqLm0i{ zxGx5^M%ng%ynp>l>+?pLxQ@{nm^rmA%BVlPL~>|87uFm0E46w zy@%V6_IB(;+Sni^pP2En#*@kM#regVj1b^wuCA$?zjBrxoRnFQh(5dB%!P z(JPB2UVN-f)q>KZF(iiU^ZYA>ZBDQ-7n_tI5s zEKFHi->KzGUS1xn;p{LDhcNbWgM!Rp+H1kXwX8RuoMq)RIyZlL?TfLqGi82ncYSMnbam+H#%Sb66Iekw*=VRuue*WyK0RyV7(HTTi8)HA;ZNqu(P@loPYLBXBa0T5V zT4D~;TueHpDPd6U2OpZ1gy33&$g*~X5;eY?h*7v7w-l43Qbam^NU2Tr;Ph>(9K=u9 z<*LYEAd=4B3`;dL;P=}3EL&OOhEuJ-9^@iz7IHtsWESyQdIqd%!)lIVH45;FQJn@k z?-Mvp{v*GN2#`)&@_ACzPZu{A1$A7@jrBD12^y`jGhY<_5+M~MqHl**CTD9(9zM7f zbAbC{7aJaz)|iHVIjn?BAuV&;u%{T51AyS}`bTiBd@fK8%a!V-eIqJ-Okx9`=cLyc6@wZ z(~;hTxL|78uJZIl7y@U3;b>;{HDD@qhTK#V+QdU4tIA~QPbw~(cy0@77kBN5ryXgS z_aV(?aTD5U=IKolkvsI#^&PD34c=0!RC-SSs~aEeU-7hnL9np z7mvi{|=3 znN1KJg!rw?XQ$TXs+DS;jxEwQRSe?{h$6OGa$YE~wycW^J0EuIN)P)6VxY z%~*QZ8YRhl_uyNxwEr3CJW2ngq26WswiGd|m0_rK=EL5}xLma!*|Q7?r5iJFeEUX5 zeuP(gE7dGZM`(WLL`tvHpM*=P`W|KVN|WH3x-kW@vYx3aHZx1(Uw^e^jdULoHw_J!Sy(1`() zjIF&NRa&&*eN+p4gzl5HXD5cxF_HdClo4nIR#GeGM7Qp4bCXUvwk!hy2<5T3JeePt zQnTdIrdkUD5DSGst`H$&YK2x0DY*+MjW9pms4FIJB0f{*p*~M^3_6JgQ$(Zf(az4K zn~6dQ42Y_cGMMo^Iys}B>?x$|6Hu|e;&jn#xG}`%mpG=ionM_9TX1b1*f|`D$Z1JC zQ;y{_zib7S#LU7VeBL4G-d}#W{p{1156!wB93PBN#=dlPy|6kyzZ%!gY0dbX0MIS+ zidW3JkJezvndN9-oL-&Z+&+5v>~wN&n~2joqH^9eGod>gS4~S$V%_C6g0wGXO!l zi{Fnl=oC)~e4%geNAfJ8A%GblHg-&vr8H9mUAd&R@HJ?NS z1F2ejeA5689@f!ojy2kzn$iR3D3Ze6g?&wDSlt5&g71+@UKZxU2bHfc?M{)&8i0dl zG?=!tMUuOCy{bY(*sOxOiYNL9L*qZo5<-43;NG3QX1RW_h~^mlm<}t$S+(y0DYrp1 zM%Iz5wa0~GhDF~8TrDz}&zj%Dj)@5xTB+%Qc>-zNIy%)H4H`cbFZtZTZ zKiS)T`uKr~0h0|X^X=KH*B3*0g1jCU7oBE(S!QEg^OD+Njv%q&AU>?=k?S0>Iv&sc z_{R>nHcg2F=T}{^u=>1K6AMaPTcbB`&iCIRZttwyixTHeKUKR3SiEMcTWz)3G3ykI zhcmGtdk`f{in-A-zy5H5xsI}g?XklC<@m6nwF29ayRL38_koMECU(UX42`fVjdRHS zo6q*P6>>AUPXCE;(;<}f>U{6<9+GWL(>$DVC_fgMV^tARTJ5rvEB4>Y6?yrcw_s&T zRI94PXdF34sFMlK=4`sSe2&e!zS~(w%GuFd+^}@cMs`i``f}o>qwI~(4lUnVJF~{@5HBv$XP-Xa-dTIH_rUgevjSz5ZH&D- zJv{K#>3q=7ej&?a3Uh^W>K56UGbDFgMb{ZwkDYPId@wBf*U$guH!q$$Rm%yMOpmX_ zVucN3XwGy&4pU!ZJJ#R6ef{dys|S0($dH-OgwCrgQ!b7LHzdMUnQ;(XiW|8Wt8*vP zR|Mz20?ymvkO^{USHeN?eR{0c6ZKqjc=|^ zee*!Kmglx9#KY~lmiF*WoM1*w9URW5LWhwzhBGp2hfqJ=?o55-D>(pHDS>mZ^lNy&H&yutA zyUxU1?WM&}49sGNhH=+$X81|wS;0Z*sqZe-)5b*J){Q}vg1{6>5tzh^!LM>vyN&>o zr5=`!1&GXjr5o;4Hod*8cZezYtuVcv*W>x-ecDFJ*pc^{-zms3oPu};YC73yjiy(< zR;mkrskeYr`uG~@xM`8LN3%S`po^8I9plOxM>_L+je_d$;cNhG8iD!9yiR>r6=S#- z1vae4<^ZUQ!huw0p&}MGUI>(-Gj~xZzD6-+C6nTnEnK=uB?9g48U^T-O8)SIEiz1* zQ%^z5Z!w!^9w}u005hl}!xZODl636|iku^;$F;&1oqti{p!mCp{aNA%njOJ1SNY#tvv<1$8SX=}6s(1~NcoJf*Gs?6hYNl&iN z$5)e6y`+s?4dsl4~(yFevcUQAv$vb-1kD~xtOrKv~ z(Z^ckK~iRN5mrZ-I38tvb&Gwm04DpFU)=uu=c8MD3Fh?#7kM!-TXJQl0Jlyd@CuW| z7w6|+zkh#*fs2^~F}<6E8L5aK#M!=TDp$Ll`hdbCY2Ow3sXkiUPIu%o?gyvY;ABGQEV&L z{N$9gkB4?q=|c3w7Pi($WkB;90VT=)x+sm~<@Isim$VBc2O;R!N{cK4WyFvT7(PFd z(+q4u&{c<_)EF9*qRV^*C9s&8O>L?I*D=oNbIxhj1f?bk*t^a31#kK45w^EiKYspL z8ZMft7@wRmySCQ7+dn=%{^s;JijI@Iip}k<@Fa^p)OX2?x;~_&24B5^4?cQY{!8+Q zKZCVRqZ42j$>Ux^H85gDoZX<(bIyI)WDo18B9hi$WuC=u2oe$3q>K&qz6^&(YUrju z-Mc=YXAX$2OwJan$z=-D5(T78c?&k^h;bSHwqTK!XE_Kjf(1(JjgdvmpjsKSUjvb1ej>YQv##yLj7@|2!~#R->-T|k%j_l8x9Qsb|)%lVAK3i*Z4|i5<6ZrV$vmbr(vG1g4&hrZ= zmc8IkwQz9JE&%h!W(u6n=p_a|vguPj8z#|{oP=`5?*Hare*MwQ=SwT92<6y)Ha^BZGeW$ut zr1oGd47OEFS;WU|eJe;LO4=J4sW{3`!%CY~p*dK-Hy!iM>-P^H+Sa!5>tFv9hHR`Y zO|H+^cQ%|wpmWlpIHtg!j?0sCFE1RO?fduQFP?n)<(Cil_H5?3!KXTf(yA}zi+3@X zLzz<{A2shj1VdqbyKphN*;-wUl~8#9;ZKEQ_>E?zboXRAiKK$PY* z$TV2i%zkzTAfF8J1tc0vp|W-rYj!)@UDe{BwAbKPm_K|P9QVeB5$P5M+#Uv2JlFm898za>$;S5bDjISYWGREq}B6yL) zQJk^S)vVI9RLc&{LZuIphLdZU!sb-Xr6nq5)RgV~bcpKA?5xC8J)kknsuuX+(iz;y z6H#lR{iJHh6y^{@>MYD~o2CreqzhGDlqN7U^^r{{r9jChh24~mQ8+)-yt5;gM4_FI z#f`=`K99atoznEr`b`m2h~?L&Uy&_Wyq08lJ?mTrx-$!#ZM(m;Y2R)SY1|o>hp;$m zrsP-o3OV{muaKz*Sf!)E-gtc#!j5UkcKmwIWROuNB+}u{>;Lrv=F6y-B<&4G#1ZOB zBY=e@Fd}Vp8>GakOe7?4@vZpN>IA@0P;sc38NdM~M?RiFAtc65+11Zj=p`1<%vDvf zfKP{7j!eI*p+P4E0M`=J(?MZO15}+RV#+4H(}K~WX*}(X0$A5_I+jza?9Vd3+}y}v zgq$H~4{DFP$ldjYM=zgxW%Kc)o$alat#t=Xdk6OT^89#hvp!_Wa%TXD2bv|5>uj!2 zK~vOPOI_Jbi+!vHH_d6XQIZ=&$umB#J~lmIEq6@L#*Q85f~@&g_VS{}&)G$kkH-q} zRpuFmwK!i6QRR&ha=EK!i1lif2NTkl2c`U_`AEnALE*w)4Xv)z)&Sf4L26A`%jhQXnQW@>?dELk&tbtV)__BPOSLE0f{fGx!+uN)dOBd10n%+T& zYHlqf4|`l6J$&Fr6(^JvpNTBrlyvwiAY`fmgxbuU#OseMk`WI>1UokwiJduMGA_6| zVeJcA`BIpXJU5fkL9ucsGEzB$SsFMP|4XJM*a|Su(H;`a&m)9~3CS-kaBMKe>jnaEu?I{_Jx`Q@3_BCu>0$~M2?4*_0h zVIL;@pPz(WanQB40Ohu-58FG)37~8AL$5Ji8fplxvruktx=krEy9Gh~1Ce)PO37Mu zCw?zToaIS|%9!T?_C2BVeD^`bpRz+skGkaFjqS}^Labnb|G%8wX_KTmk{|ZGGcq#r z&N{oQ&zYW`Lo-|&xs)gwDH>%GKY-rJ^jY~vdLa`flkLcMcXsFK?& z!NQVt{(qPx8bm(lD`@vDN2)0HSy$% z1sXeHJgOf*Ex3*F1SPmQKU2 z%1X1Z?~J@}-n`n_?1@BbT!*UkR2ozkoDh;$xhtj0U0btA( z#~**(@1JjNZH@W^Y9K}lKxk}{LuoML?jVQ#09;Pcyd{V9qPo<2M z%W#QUuLhoKY3iNiq_}I?Ertd=8mMerSt|R~IYB=do|_2kB`~T1DtbvC1h1!MGk_mvWP8 z3ld?$)Jw`ZY3j4~DY#lgzPFf@XWq+!rr7j3H34a)_saPg&2j1qfys+(9=^8FgcPrP ztq2dY3x+@7z7M2D5)J}?J!cG>y@2!#SQU9Ar^*?I@Z|+3r9(;WCaoSU{wli<{AD@4 z@2&OJcK$`OUm6FB^ItQ67S)vz1)Mx3&^cx^K?{+Uht}9p;?Tr_m}O0Tu3< ze5WK&U<0C5Awq+lCm2Fn64+FT98I+5xRp>MJ%R7k7j200LSLM?lmY3G&(8Mhfy5Uk z^Qw#-qPM%VefZ{$4(;XBeH``8*IzMZI$Px6@GXE$Sv$My`+NIOo;-TAzwJo9xjU8_ zy8+x?eSUp2V!Bo1ti3v*V(dw&2_ifT56Hu^m_H-*ktx{ykSkP^9Zzd(V$G72x;IJIV$NVZeC2P>VwsANN~_*6=pY9K(aN~kbU&^C z!hu;Ub6sr6;NKKKegXFS`l>!Ew9d9k!_ZkRE4g`y zpdBe%9GB`ZvJ_^edZaB;VsrHT6_$$I}*#qX%0 z8rOR(##>njaXA)EEfB`0?(Xk~-R|n<);>G_aD3xzii5*rTYT-O(4pR5pIw;(FxI8w z+O@s7I<%&N3LT@*b!uXQbMUEL;bOR2hDdF_cp_1t&V{8?o;cWr2oZo`i{@J31o1K# z2%p3(Ug2*{GmFze&0YhiG82+d)$CiGyu?iamj9pknC8{scuNKxLz|*0H6h#DL*U*o zgjU7)-q;N|52SbcB{U1QiRi(;YS82K5ki-aX{i5Gh%jD)KD|qyd(xqxA8Bo0y64Xt;%>sA%ZO zh8PpA04bv!7Wasd*K%TdtKuX?vw}PzXvs3G{ozT-ItRd_3~k}$@Rj+m9>rv3?rM1O z;+d06SN8Vy_V;(UH+$Jc5RLO3u%hT=-irO z>If#6qwLzs8iUfCBvr$zC0msYiEk|JGTw}pM%K418HT8$6Gzr7MRCi&bfBO)eI^rn zLCIi1H9hu$Y<4CXh!BjjQc*;A@cj~PsJ2Vkls0H@cQ8YCxB^ zI4_!2zOhb9t4VW+^(6|1oU{g<5pfh^DX{DT&h#Wb(cquMWmi(=_Bpzd3^Om9er(xk z24*h8$`Yju*8;FMHOLY%mL2&m8mSPn;>1N3%K+=c)}}#d#Oz>@;>h0)*czjyUo@6` z8>uZ0SzdX(*Kym0iN2N9?(cv1)!+Z_5B8quDc2p*Fg7Xhc70>hm_=U)+li1>K81)G zMCBD3s|!u*YmP0MxE6?Y6a;eKgJ0xk`~*|de$U|*gQjMDXc3aUX3SEs?LgYb^$ z!c}(0;PF{|h|mqm9+qz6FG|X&i6pIB7IKF}?j>NUt#w_X1t4js;v}h5k+TVorV@s$ zvICT`_uVXC9t0MHyeK)CB6I2aBi|!f`LhA6Xa!95#*0HM@LAOqR0%V{OK+nk;T}AW zB$2V@LI~8Zeyl($dUlRw+lP&qgYnL0k7fVx%>guCyx7|^sXaXX`7d7fHkMs;0ug#^ zorFDa#pR-d35*>xClMs|LpBMOIs;-jxe8RBLHS$p1qQ=ZLZjBab6`#27?bIWJ!eFs z5u3Gf$D;+94zRYrNE(eZHhRXQ8p%bwBtJ5ZmKHBFhZ-y`UmP3^U%bp=X#h4%JR#Z0 zNCZ`2aBEn3Hji+m;P)y1!u&fPud~RB_Mlc2-Uv&zaSsv2Rfx2}C-)ZNy6 z6-7gsRAK5EAxER3b~~{eM#r@40kSun8ylJFvPvv!c=$-c)bi26S3mmE^I!h*bF1(P!Svq#*4gRX<(0MN)phtpGL~y4ZS|32gNO=5s%b~BwOS^p7QrJFtmufs zuRJ_cI1lQu;t2PZH%cNY72P`h)ht&YUmrdz-MdGNETN!D*MofjlxvxOKUTz^+MBRr zbt;itPFgeZr7~kAmv-vra)?DVujIbCcnQz;x$R9hO2iGVEa`FQ7cEaoWG#ueLw7HZxp zoBDB)XX*`UQeL!CHl{Ldt9ZcYOl2vdmOP=PhHaFL=^~A5u3x1|up}H|YMI*p?#6+o zQa4vW`eYv;kB9w@&c?s_SO0ot<#K+JO>A^EboHJYCo{rp*;j9cMx41CH^~T&xOLi- zc_$@PqF~=1^@J~6v%o$U?bK`(s)Yro_U-QNT4B_6zEsxX8JLJ;vc(+N?M43#loWPp zo(Ym{N9xw=C_cFiW}Kp>taqC`o9kx>Z{Nz&Q2@wwNd<<8Z`C(N(uwX>1V{p0&s1^n zjhoa+d##vBUfDFk;VlIek~ryc-?^5`)7Q{s>SpmX6No*MS1FdsICGfz(p*xubKKq7 zA_a@?Mo$C3zGokB;k^6DIK<2P5&37-Cw`_V4oTH_~6)5mw*4?{pIEseYRSTFXGzP0x0s`gCNQYZQg(K0&7>?;Wgexg5AwMCj zE&24W>K^zYM7&}hrfw_FEQnyz!u^#|<(E7sE>#=tV4_XWz{rU6+SuydZ-S;_v)JWn z@!t>2o2v1gFkHba-a-UABw&#Nl^X^P9VzX_^QjfBL|Q9O6t)hANUaJH4M25FKe;Fc zUqz-!u{K6*fj&2;HxZl~ox8QuY}6l47>te>^Sw%-QZbn9@SLTTvWkrh=CXZ9ziLZGo*&BL{V+`<5E$Gu>&>A6 zH;W`B@GIP-kT+Uiar$5ih_J=X7&h|DDEWGfPTGbh5bZU8>7o>8!BJRq9fG+IbGV+% z>)op_UVrk@GZ%56og6H$uKkO@`qk4%k1wyz9WQWo)i;;q=)2yQnX!4yc6yS7v}F`x zvY1v2!R_d;vJ20u^x}tx`3(6Q#{i=XNK7)K*L!h#dgy>upJEIk(5`RRyIb?tY~9{< z+(c!dxU@7H53+{rRsmD|OyGsfqGDhKW(w4@55<(-t5<`Nan6#P^a>DYHN`V?vYt6# zXa4Va#1Ah`$R31eksGMS{|{3M_u9U;_$^7pq-dr1Y8lul32MmR|1m-?OzbbrnW~Hi z;h#E9YB`HAqjx|nnFI&i9HAz#xkk4zS}C5Pq@jfxXa^zd0k1RKiGErxWhu@`k^zrkkOY{OpGiz%d>9XXWf@>!d(2r~taL(P?&Ha@PtCqz*ee(F1Kl_9Q z3w4IWV_R3RE=Q(`!H-%{2R_L(gLg8rb)#w06I&;5Lte3i?X<&zGdc;uOP250FT1*` zt>RmA%6(_ttasM?XXos7;#erT*jd7FYb(U%E5J>u(MT92Lz@sPO&M9PhnJYg!Lm#U z&Vq9975QUz?XwRCZx7!*db~SwETER#_OqobuDjjN{n|X>G;xrdeFIMx;8a0pIUMfO zL)R)uh)?aS5Cl(nOnnpAKDB7G`VyF`sd`KpXF1w@L#5P$QR?SZ$#HKN4 zAY07fh)!BLiJv1AxBymW%8*e}wS|mmVnwE)s7wcmjo?fctLL$UZ^vWyddnCLQeBqu z{K;dvr{;dpHBl9fe-!Ci4I}_$K%2iJMMt5b5u75_E5hgSP?7>R(Z#Cyp=@(7$XG3+ zHVslLA!OgXeuM)z<5=I{J#bK$atTX8^zGwu5n4FkFqk2VtA=UgR4-izU>>A`I zUP*KAVvx&XzV9g7MTZA+w%$q9R-$^Vny!u-lL0=?X&}x?%TUhl3nJ0r5v{Otp=*Sa z%Z;t8yBLniKLs&5c7X|)nkOxHwL_c}e56#Qo8??S6u`=>a((~t-4@lpB8A3)0lXG) zQZMsRNSQi`Dlkc?G8D4mT)Ojn;3K9Z)ss6awQoG=KZ`dB*ZsphBbZ272~C!Jwy(%C zOA8(j0y3tB+d}gn*nDVmQ5@j9qZolB=ki8$r+CiUBx{OLlo?MKt z$4>nL*7_!WCXCQTm9>HdrAgZl*Ge^eHjiim1^XY4DD}EfieQLh3mzkJidyuCK^r3# zp_*uDS%hz5IF?0X`Yjy7C@rrN)#=LXRRprtIG3fQ^oY9H8ZmsuKhj2`6?&~uK_JQj ziU-r!z_ESX{gu^(caFuv_ZAxx#^fx5u6GtY&I!FZANSAbCfn=FFP=R8P6=nC_!FB6pN;86x2{NbY1ddQjTn-(_p*h=byc9KQx<;dm=$tVHK zG5NS;G3k)iVHT!et{s=>GQn8SvMebbZKgr1F%@%!HzL_1N?;^rwoBNT@3iU8{=%+0 z$2J`cIqjmZBTz%DN-FDgd=DHnfq?)O?m_SmdjWa)p@9|B!e>wAQY5dQElwjvHJosQ zIztCjwdxwO>znIe{qft2J}a18&Yq%#BX!AHT11GNK^tZc<&CRNKRV=`ibuxDLzQ!C zpz0ufAJB40?!0`Spa^+M9qd#Ey*J7(%7d`Y$=4pUBfW!_x;&hh5n`DMX5G>N)Mg>mF2oqTa zvf-(ucK3l%JXU8b0MUvudqUI#Td6YM4GmIU_@q?Dm*LGy6>$K=G9-zQf$Ph=hsNfs zge^!EuNwdC6>R=l)|9>ETdh>Yz>GqY3CfvY;YA1`vXUcRE*2s53%!P9=Gj`f=L{R5 z7=uI>2!dyG61swEhQmXTKG17}jAQsoL(eb2{G%3SUzUt`M93V?{q54a3y9J!XSi7! zu7yWej?u6bB&$TzE$R!x2Jl+Eb)1E?Spg6phz{@|JKjI(LbQnxOq?ndR*%IjtQhtS zoiS1IdP)JfJhf-i!Pn#jMoEJvl`v7G6^wY=Gm)l~-A&vi>i|o|=|2rA1F~Fw3d5kY z=S;{7N9yn`HjIT>+Ss5(C00pdOXnOPf`hYtYsvUKmTcNtm`97tC5a2afd8 z85mrs(YD>-+WM~|Y0j>k6vnSYhtB@Xnx#JT=2786OSi*X6BAP#H>AJeXDh0fPa>|Bg<$nl&ZY_c!g*SQ3t3q>Jp{V&EczGL*mzoidjHNNg{sI6ps($OqB}^j+KZRC}DoHm^34;|04}K&!;3~E|mvsTc%OWO1 zSb|n#uWwMV)3+y@tlVaW3G&rwJzkz9298btFIBdh4juvJwMtMy3%GIJGyQ6?4{{zhCLIhJ)aDqV}r z1Ad7aeQscW-|2v7@e8{YE*P30<;oHwq)`^=p?f#em8KwRWLtTbZp4~AWjYWfG$S#OkUHrQC~HLNO%%J5kW2{1~nHJ5r|6E zqDzHHa?w*FEss^g)lmm#YaZ29NUTsBZh@~ag=ffnPaSJYM^sRaX6l&T3zI5AxMun( zns0bG{F(+Ofqr*RCwX%@a%PpK0El6EEB#&p24|z4?YaG(&dU#;efr6V8|$ks+&(*b zyJll+hy?$jq*-Swf{GdG7%owoC{ihmw{VeI3J_w*J#sE&kycm=TV$-)l#^4ZB1f|4Nzk|U#oXuhZcC3g_>w`LnwsXl-(AtDq}lZ;6z8(|WMb;qnu!Hgh;d)fxA*3#L* z7<}|GT*tROMkDr%*ThfG(@WMcYc4c>P-8$Rgsvg9D27I2@~VRrptI2FbfKaUip zUOh$7nSj=Wo!3^))8S;;rQo+*<}^;PAx|nN{E^IZb`hJ%#O1wQVkTN#10u)v+QJ(e z?Nf_Wid*8enDr?L&*HS?^%Tuy3n3fLSB9y49|HBmD<8 z%9z`_T|G^8v{@mTR$h!>m90#Z`VEJY3DQL_HF^e@cZ8KotM4jLbtVYAiZ$mAH#h$M|uZY&kweBB+KksvxRW?fHVo2L>?T~UwN z0}?!j3DBF8ktWmlp@?ijUKkhxDwGJuyXjoT%_8L-3Z-K*-J*qOU0<^{HhkHNPySG+)0ftG+lK!S6;q+>KF%7)nX-mjgnJ7*bhqv)m)#ci_9!V0;+_9>><(ttBQ&Tcuzi4QE${;FD5g zQ%b2T%n4Y<50>DW`nGtX=E2BC<12DD;*|2P<~r1C6R}RSHF%;yYbMF(YNY+ztF6sb zj+Jy=vubnKmkzav`W)fj5Nc1T@T9SEB{MUwD_%`N$*<~BlXGJF#!aQ9*XELZ>WFq@ z%4F8Y)ZwIz>>3Hg7jc>{zv&Y!6)c-Cz+|z^m@u)RnAJp)ev(88WkaflTLT4t3-QV? zVH+FrOQ*BS=OVlS$nzq26uh{qv+_m#@jb#WJXbC-rkIxkRUMB{N~B6SgT;_4%{6Vt zpJ4-&N&oav`ys^Co4QhM=4wrWu5Lco!8^gd;Hx5Z)9xHO_^4HDant4;|7?#KEn4g(Q|v?1U2nVjvH` zXqBr}9oG!BI@dJ6F=FpGxKI&;2{J)t2DO}1(L=zv>Yju`93)K#HnBX!gz zoavmZ#n|j^cnMgXpP6k3c@>lb60|@LgT;mGJkTr!n=xj6`@u)(ToBsV_Bx9hXyuA+ z60>Bz;EX=mk}4erSldWQkrDAAo**PfMKZYVF;P60MlujS}|mmeFmeT z!8qDy20{xkb+Q7 zk_+l7eZ$?t^18W2W^69Ru+R8?H^ueCY+8{)wyuEk6# zGNG@EA&m$cQRYz;TEFEBil=+2p6_A>s(Y2&$eVVUA}(7-rjR#R%eO0bov1!a zQ25e69Ij>*#BH`07iZ2F0nt;B;WtgMbZ!<0q?IK+>ByKgIZf5!y z+^d42)I>_Fi-Zc%TKQUGJ%dht@N5Uni4;V>v;w%;7QvO&WF4-^ujIfPhgj=@Y5UR|rzL~yC>8t%cyd5H@;<0zPs7$tl> zwWuuqsL~cnLAi+qNy4Z2N?CBR3_{k_K_cP9h=PLM@DN|RuFED5ZLs>PzhZbtMmKN?rr4`Q;?O!WQOwaM9T3jrmt(u6%8 zJE^MFc$jgU(%)pC}}vsh`Z`grTP-pTnn>6Qtj%xVtd8lIv#s)Qp>P3 zuIgBUWd*GBZDWUaCrPe(>MkzzRvfmy`0=y7)1z26xs##T>5IzK<%-0op-YeJ09pkWmsem8~Q z?W_++@mOOcG8Q#y2T|S*`+e(ssY*uT&ic(j z)Us`dALLE}tEn$B5<}DN<&_1$TA^)4tIGuP7L%@sZfM=veKZFKkiBOtc01B}A0g*D7OKxJ zzCJm%V9OGNpa12rRIzymcXMU9sSmEYvA~q?GA4G@z3w{Co>9fmLW-Dm2nwU`dpDt} zH8;xUh)zma~VQ=dg^e4mDLp|J-SNLNr-j~WaC2?6i0Z5N2^0GNs}T}1_@3+K?`yU*8(cEx{vmC z^3*sZOKxMh(c6^r8vP~0_n+e=;qdgNa{~=yMEM9-(MTJ1UeY3J@uTnxoIP>r%%4;b zzN#%11e$V$nqZ_4Oe{?`QsaGB5ffOVrV>69YMT$oGdW)Eiyn{bqD_Qgr9aG>Xc^)Wc`;fMh zx&NqHaSgu684*mtBZ4Z{paPu>6Bmx{^}%xz1MnbK1p%{UM-Xl?g@n%;;=`TO@?+`B z8=BQh?i?T2+6M8gb5@Ht*gA|2e03L{i<(o{->Y+2ydaLzwm6yJ0!)9CCQ?Ks+QDS>1C)y zh-1p)P6CZMNAPsgwf(LdM&THF6PFvq{2)%fR(~pShIg^u7uivzWThXPc=1?k50f-u znZ*@Lk0W7IU!E#C?{naXrMSk@6M*stJ*<#YMLat&mp{jt(iti{vRylT^ZNCr zZ&?Uj!`!S61@xAQG^NV_+C~ycf5L@;Ay=@OATy2WQv#>0YvyT%5lcvj^3QZBcBP#G zjRiQP9YM0>+5U>H4zAKfMR1stW;=SAyPU2OL z0;V>{>9_jTIcBV`CbP09kpr$OSg>Y9S4u?(J?m%PLAUCH%rxVatZ|u)vss`xbPBwc zZc?O~NH6xDMJdFBZqXbCj0Th{g-v$FPm}T#Qrbx9Jm~_1>a;4SqUeDGhsIeWan zIP9DHurk`R=zFJ%kXwkIvr4n$fTo|0kai9iV;;{^#@se;uo}>@nprK8&DddOr9ehP zob2buS2wnYk9a)LFTZ({>m@yeGpCkmy+_AqZ{8kSaro%j=F_LoPR>pNz!Vukq=V3z zr^^Z8av}262}lXz1f|NF3k5PDS4R^!{W5_gf{^yDk-Bl3P*5bB#cwfNFWllMOVSW- zwHzKDifcp+0u*`ALTmz~fEkO|rjNj%6azDXW3>RX6PyNX5@gdWUb?-r=}DL56{~nc zGGojfB=5*XgT5^MPVLSd!T)nSQU!(9 zv6OH@N`ZRWhsl$*e;6d)(sx}$;JcyTTLJSsDMdV?_B)rfQ|hO$M1glDewPqazDqOp zA-fTUwO!4^)q=Cl&f(c^?xU245fL97*9`x)LFbmlI{_f!Q!T{`}(P+yC`{`JaFH+rNJDXmfCWl;NEJ zkq9(6$4^WzpcxnjM@L`X((*zoXRms(2uqoXztpOdB6T$Mvm@rlhVGjs^ODQb{{`~HXM)mNmvmX zid3)vkxJ@2gq-o~yP8qhM#J_sdUC2qHz-BTKuoJxn&419R`a)K;8y+31VIF=c9Ic4 zb4B3$K68*z5aCfR3#EoY!>NVj!}L=N7pFDAchSVqj>?$eiF=egw7R}x9nvf7_H30V zg2`QR({)A@&#jq`^)&17PRN4Zh{Mw|`17B#{PQFKIWZf+S!F?~VfC=~rXi77V%BTe zqv}XS{BS7{pjk&I$)#+=QEfSTU6Z-mYDp|gi*BrqwtTLZ2LJN>fEgqP+~s+*TfMHM z_F^`{%{n+0z#k1F1D8@RT&p=^4@_far>mK&#-DOd#Y0C%_3#_=7H>tXkvpu5L_w)Z zuojqTQ}rsMKE_jn(f|xc7HJx-WZ?BZhkp`vLbyi07Rl3}5Pty1A3C?j5WU;DH*u0# ztAe1>BFI`i{$?b1l@kez1k`}Z#y$Kvclqei+{MMK|MZ{!-K$q$#^j>Ay1aHsm7&;s z>Pa;t(6my|nM(=dVlW8>{FD>jzPq9G2ZCYH;CvC4#}gUV5p_Vx*e*k=xrO1dUk7PB z$DQhs!L3y+om-~f9L{U}UTryAMKTd$!TVkMQL768*w~n-2)7%(&d$!QpYIQcM<=FVu38r@by)+!WRy`z zASD8q90GObk7ouV-|{IFafulC#lh_@?+FdJzZcRZY(WAne~ET?PoLL3B!D*M8&-@h z&SOD;#@EJ;wRbl-V2hFYs2f*fMz?(@WPu7E^^-?@Rov#Og7Hq`RNzB@36Y6X85FBk z3w#)73h5vFl>}OfRv@Y|=&Z^Kc}I~c41rVcyI-09iF{M6_dlhb1Tyge(xliVY^a-< z55RkE4iGxcyZoj;b6HVOiZ_Ju$4H%$7?EZ1Gh{vamu{GM)Y@%-a4k<@OD+`SmUyS2 zXOuE=DP*`YJ5-=LDS}kf2rZ<@RY48U#>TT+ghq?c)6z}@5}dDrUHP5le z7YSSKlb%Y~KsCu2nLTwPE2H^JENe%sxxeLwO9N>`Z!3I$@w1n|`o)hf&R;unn^Dy@ zc8{MvJvu%xZK9P)JHxr*P{b7W4n7=+ONvDTLzpNynM$H^xbQB=3=#%Dz|h}>R|yMK z=ZRgG9C=y$e0X%yag-tJBbDSfJGcO+OD-?2QNXO7HRl7Mn{rbqeFds5S$%wC#zWxV zqrHPShc*FUUN2jlXAE{bbi9I8*N#)oFeg)O>Rq|A>WoGYBhroC0O}KCCtV<&kui@( zWO-(mGiIfso3gHNwc){VM81Fa*=GiMufBavygJLqIZDr;KR&%UKRX`lINVsi;iRvg z^FVI5wl_w@%dftEYy7aYvrRJ_4KL*8>>4VqU84#@?J|ky)(BcW%eJ$Bzxnpn*@#_Y z?!%9_AAj&nZ{%njm{1?F41z~&Wn5@qPK}H+ym;DQOEL?8FWZJ&m9l`S10{^! z5Sf=&m~y539w~cXCy0pCiYbnbI`6@)7YVK|2krohWwttINunLIMAr7)th4Z?NCy<5 z)LGj$b*vtJi6v8JHRq>^g@_RcaYbDI+T`+GAsd<$y~9k%@4pbOF>~u)r%GkfrwSC1 zfgCNpD!4rp9M>{!1Z-u`6WD^#o)uB984@L{N)}zFSz`_2D#j#X@CwU@E{Nf}hVBYu zSyX%bZ=XM1{j0zF`Q3HDx7it7Tv*nS3IVQ zOv4ogNLXYyzoLQ(DJ>C!N_PsZv>)PL2uYR|QlUI9Ck;=|F1B|bIq%64+;WLbpM`7`2FiMd!B$_@nUDADoJ3FJTe=;KF^_FO{# z%{PB^s%aKHEG~D}yPJ#iUwkoMUYm1H*orHCoks64`Ku9HsO^KdXQZu8gh9Yrh1S}b zEmMVrtb-ZvZWqn8i9Q&Nj|YR({OcQtOk}iW}alt|8mx(S+TSlBqFDo2G@KHka`u~MojXgF{J@oD}8l68V<<}V*)$t#TJbt z=^G=aM_lQsx>BTaTTZgB=>yQ7@+NQY$0M42PHnbe zX?|{hck%Pjo-fY3F6o?-baOi-INrQ|V}hO%is`4-@*aA!Xzu}Ajt5fZC6$cjqY0LB zMOR#o03{J7?XA^t0Bg*tENqK1kgp5v-Oc6t5F_-F&CA#6)j?~C>a`* zBXHKhU98? zk@oB)?%~iOnP$44KYjc}H8917NLFrH&KwEJdQn0)O{!U@be!*2-OMUX)Z+OtOG(ev zQWWz!0&A=q8W|gT`FY@zNaavNLg8>A4a837u@>KOlhQl}C63>$Syc z@qOY+MrrZyF`Xs~ZH}I=pv)|61-)*^L98R!Q%$*D@g$5%hlZOYC}o|0i!UCpCy!Ep z0-!Yu0SU#DFw}YH*+NJ_;SJYDvi1~GBeE<6X{fhi)2kIJd8dC)@g#?($jM+@P7(M$ z%U+jbfRnuPR0e5_5u42{Y@L+t8Bz*q+%H;aA#M98=lX*{Qi$m*45HL0Ya%N`j!I#b zA{c@KC~JTSG^~SUZ7qQ4XjF?tv+g&GjiD3_gJg7(VuKRl*wBrnR2zL5LMD|Ja}GW? z_AIR>n-r1JN&ooNgoe2p^Ty6Uwjg zoG@(>=6GfcWuK%KPAwnOkQnn zbTe~zo%7lOfnC7FIuT}L`!i#2ijc=heK*!}V2M|wA$CW$gfZ_4gRi7ZbhXG)Y*FZ5k=!4I(W`$0{BwlH`+ z{May4yyo%baO6y$ip(!i!iR8#3v5p|k-$tbBjtFDrh2y2mDb#1$^~7xkpFv5%%~?* zH_NNiMnp_P8i;^F<-GV55i&)_YwJ{|kdm-PYc=sO=lVi3E;E0CHR+INMy5vY3|$d= z%{WVJ>J6-vRxc?Xj;eyHv!L9VLRT-Hf*UMAEUn3;xJ;cAyXauR z`~BUHE22OBxbxXZ&(}J(BNM0YjIgNztFF|7afzZbM(#f-!VH~O>W8oV8k=xWBJ$E| zaYr2J_qBejkaF%UG}0W{h7B(w0t_6qz2WG}%_+`z&#`>EDAVpMk$@1;57JEx5iJ2X zQeIrNuYtDM+uBr@Id;8H>1Gerps35bYb~3#q}yA2^yK91{Cd5+*mcGj`Pr6;5!g%a zf^)bya!nAfZURasoviDeA2Wk8Q*@kz*mVl#S^r`*y7=Vd&wz9M+v8_*2C6oRIU4%y z;j8||U}LM>ADmiX{rtr<=RTC8lh$)*N2p!)_~PCwE&~G=1HH+bSsWEKzPy!z6IY4O z;n?A}IZl4izdDf3gH^v|H5{`>L}*%yCq1t8$z1Li{$-SsYtA9FvN*2Q z4o|BfY)`c^{?jn6WKcn*Z7M3_BJECmPx0{yr}*u)ftq|>a-C0B5j1uR@C*VH6B;}% z?JS`PO5XS9RnO5%tlQy<8=kJ|e&Y z$@wxhy2})3nno;S|Fn<|PU!$9IL>I%0;a+5(MW#6;UO}Qr}A^c5w{VDr+u11Xr2bY zO4>S}NlAT8gc7nXPTEa`kg1?1XruUt00}gGL5n0&qc%sK&Pw~S!-$BIR;3Y9 z$fk|jc2fmNV#F$y77Bp3jAr?J<*Ww$g^(V|k(Buapkgw4Jv9S38UeWuiQ^R9Q-WYqb|_>&Kx zzI^d`;eO;4veXTUmbPm)CWt^ju*wD60~f3+gl87akIkeK!ed1gElWcrNDV$rZ)YqV z?MGh{1|{vH%(|+;Xx8M|N{8}@*o0fODHRkUTiu7uq_Apqb@lM=0qMQgTi0n?ss;Gk zQkOkbj>=%oWLU`6GFzK3KU(;IfBp5M4XN{2j+G7!rf)7?Q zz=%fFj;=4e8=K0wwCWbD9HSiGZ&lei&v;>a6j^GDpiCWWH%5QPC^tx4+=$HnsUCtq z>(jB#goQOWjUeqn8%|A!LrRFvJdJI{0E0h5LG7dh!*SILrpiY3!a7;~hyBg{;XvnV zjHr)>87@kRBZ#81N2nJQHYFojQ|*)MKjlJ#{IB*_uYx6Ym zDq0JC_~~J)KrOncq6#lsJbdPbJ{MpWsaC`A0Bb;DtjX(V6T z#_WkN8}`YauYOtyTS8A^Gt7BfNJXqKC7r?(T*zNJreWvq_WpP@KTdX#Cv>dx!kn5} zQAjl>QOcDSixCgaQ5}>jOu)SFl%od-ls~ax!o^pvy;*yd!=0ioy!+G&GL87&uShZt z-xe7>XE5I{W+M0$g|IqH+&@Vcj#qeli^F-ML0iGoZ@``=7;D?eOsylTKmBm+DTUUt zTUTs2N?RQ&T0CH$W{pmMKCNMOlhO^oJWNRTNiHNbt|?6s5_8H|ExFg`yjJ9}i8KQ9i$A;|ePUatlFbMgXgnBh zZfozmb6s|IQElu>BoE!}IikkFYS zJf}Yddg>;^5ctqEtuEli$u{D+Y+Fj?N+?qy35^Sciu2Cn6+vU?1gA1i2#*W|WCGrU zz{$)25OfC!u*GGxRWb-n8QUvKo7Kouv>J%DjGW^s-&UAO$b+Ahr=iqnN6wMb4l}Fq zRQnXfQ;Oss0r}3&{DrrCLz;3t@bDDLt1drg=w@#$W2QAQ5o; z^4#5;W|EQD!g_ba){ND2lp=`9&KBYWsd_?sCkqcz3mu z$|6rSHpe1ZBZ>Xo9%xX0c{#Qix&Q>*0Cc1g^T_J*C!hWJ_y6$wlcQ76(TaAh`P1)j zt}Y>E?u!}P4h2XFg%3l)*5*1qt%0?Z_0f~3GWxr}`~Atu+$TTU{Ose8e*NoTpBx=a zx4pf2esTKqpZwVV>3{h9-;oBJn>$vc$@Jjx6t^LQ&eg@$WyUlMvTQ}NI=URwXDPee z#N^r6Z{HqVtPREkqLe}Qer|nZZSU#+=Ju{pBg;d&NSAFpYr_qM)@nItYu=*KC97*9 z3-l;@%tJjtVNy1FPLe` ziTU88$OM<-8rnCJ6nPmD8=OoIlSOoyOu<*_;GhkVrA{}BxP==KsiBB13QUu{O&G?= zFv$x-8Hvwa;J=SaD}|!)M68!<@n=QxI`b>&)Zc<9#F++$4(-D%geOqbipVJ$MW9bK zQNf`@NZ}WDXv@W7klStVEX&y0>OFb9^XbPQo*aE;T~l{c%Rwq4fEum7V^~NvflzpU zCwH&Oyj=TKMHEtUw8AH%6LM80-5w3jtw_+&*&8Mhw+J7hU?k{C2_OV1S4QHNj-iOk z%j_j%F61Ze=l+uM)}chpuEDRmOd+Fq%Tel}CoqQz^fqk1WWpP!L%p%% zEt+o?A*3t%sC?}1&l0ZDQ0ranZEl%}`ye_F@}o06WbAy$hDz75OvEQBUfd{%6MzH)7(5Du(P-t!>^Mf*Oqv@fm( z^0I0$fg&P3>kECvXxO?FZCh=m7cn zJUudn4^xYlq>#%K5&SaimI1568r&*^yeqE+6(+@4d24u1F%07t>k?-Zyej0OAD)R8 zkQLRh@qLnY{^;P?suNsEbV#;0q}k`a~4xswJhqH2mG&+4$^}k4*mf7~xuT zO;U)NsuwM#&|I|GG*00OD3m+{u|kr^16m|>T0y)qQ$$nBP$76kGDK6JuoGsYhS#Pb z49QL@CKL1KRu?8Jf?$dqv6$Q_I0Dx$B+Cdgf6dK}Gr5|@Gi7o%X=U|>!m9m%3!EmX%4X*vCZGGbu5n`^I(lD}t=`7pe)%;me0OJOwbMI0H?K7}8dA*RMniT4 ze&+Uc+9amM5BK-kp%C21!`|(NW29^nJ2*bJHf(icmG-*#_Q$p&P&F2|_DW(?c&%f!U#^UsPNm33 zDS@p*Rb`70EZ3^FJ%#%kxyHCggAE&{Ek#WXr9>7%+fCaHU!*N$SQL31Ra1lsKg+~B z7d|al0u2`k(e{j<3@M8L#EDQz#v)=POXU#48Wrd%Dcm?*kv>HX<4>d26d#{0x^B4@ z$8#oR0RkuB^sLD}V=Sy$U2|dGnkD)rP6`tGH@|4s+X(>!bEI&-N3PG=)@z6*@5r|* zAr#dI29-8wauqo+4L`GyQQOL%#Xy%=pv7E34Z-b_`(&H9^(_n~8@rpS)zH1h;<0o` zT1iwz5557MJOZsXRzb;D9n>fTjat1*r)`Q#nt#7XaYDs2QPXq^3y=Ja)n+@_R55OYg)3ubjpg{@Fp+NYlsw4hTms1+D z7rkQXZ-GgqaKIND^kVHp6s4#N4z;+@N5sO_w~#0AzD#rd^L|wdq+6<>NWKJH%ol}% z1S4}T=3$zGsPzMjZvS4I+ud6F=%YucM_;e4IDka|<0M>~0@2(mQS&aHGiSv7z`DyB zYBE3tuT#7cqKM=fPw19Pyw5E!W`0b-tTCU)B9*gceL-_5Yq}NmQ-`_nsIR^2` z)7_<|Pn}%uL_J5<{Ok65$ScXiFAP`XpqsTzo#;(%5=2q7kGCar&GXUC4d%)g^@ROI5D zVqY~v^0sWuQmVY+j0w)|_44{&TUvbnmYNBrHKY)|AG0As4#kY|b!V|Zm z8Hc777ZBfAkrdM_T>8Tc$06kPfMz_1g*3n*NNcN6fErDOsN?LBqMU^P%iMM;S|nKOOUr(TF^nyX z^wOo=o{3R(&hG=4SCU4NnF}11z83umPRTvX)Cai5Yhe-oSDm@*Qc zS}(*SA2|?!1rs-WQo2x^;qUycuOe(=H3AheQas%du!g}A^qy)_o6%!?Vttl4Icj2_ zI%?9}1#+p9FM7PpfS3ni|`R9*+`uUTK(?8ta z4T6NV1y@&VD^{v@FOJUWR@5ey^6J}H?1lte8cp$rXX>)X_3F2w(LNO}p_KvarIvVO zZB6Iw>@D5G6`5MKgiNvKXh?iTOK*C8Ya+h0>Oj@y-o|!+c)7URy}r65EG{qmqz0Jo zY@>O(IEK77Dv$7BR;_7$2f%>&>doF{If6?nXE8x|7cNL#q4BpzY8J4@deRFhjOIIFWzkIoo6|SbgJL~4kEXF!L zzkBk0g~oX?x?JfxhG6mH{CwFW&xJ*|v~F+rvLf(yyt}=I7oI;^dv!26AI=@UI@Y9L zeEey5W2-+LlV(n&Tw9zwI@O48TwPAX84NFQa<`kQHiD8FgxqCjrIwnSV|dBsm!xFz zvrN>83d)hP<3h8FMUV8ckQ%*Z`AjYpK4p6hf-~`cX3xJ6yWB^2DEjIJLIXT~`Xr(eP!Ck%&Oi zMcsGSmM_M`JsSa^?B~)|J3h8Ij!*kJ=t)&-g|Ycr4jql*lFG^A4=={-^i~lJIF780 z;Dbl`W6Ya|2eamfhYlZZ(ba>ZG*vLyiezt14i3r&_6W_WBw{Tt+fW!)9~DZcEL|Vn ztydJBrj7ZDN!^gy9|sy^ZgnG5xFoBRV3RRpxGU1dBlX8w0L}+|N=VMeO(c%e2~*1= z!<7f*%sOOIvpM|;<>VYKB?6}TTX++-{MK@Yg6WggMNADq(E>pTBxNi5Ww(L(RfWp= zp}wSr`R7-=hHPiqp&pewrpY2&beWuKkj3#1;*#EpM>aM};gn5ZS$r&rnmkcdUIc=} z_Yf*M1Tt*OsY^@t;Ak;q$dX0!Y3gpOR>*uU`52Owo2CnWks10XI@l?YNKAo6$p|%` z5(Me4TX^$KA!JO2&S8gaQjH#KX11F4^O6r2+n1?_?B{NxtE>m(yo|Z z#2vUgq>9BS{il3Pf81D)wPLE@z3?CX=;M{OmDk^X`|aCvS3kUX{#-_{zkQ{xJ%9e( z9)#D<&HDZCh}Yfi&X0fc!TzIdT-P4}Pe8E0oYC_8gOjx`B~;gbZLceho2+Ni-&|Wc zJ-yIN-~Yz(rPd1CqN6Yk)HV+B#a zJvhm9LC8fupjXi%!q-`IBsNVs3TLisMI#slj0`+e)^ulntVE+drD$ooxuU}pSpB50 z!t43&aa%`aHYy`WSiCy1lp{GOW~;C!5FK8p6-EAp3Dl@u;3I3~zDmTc|m6jWB{V9S@YJAv&89 z&*|2hU(BgcOGTcFB~P*Cz&oGIn9o4#BljtJ<+>AzxB z60PKSDiL=^WMmwbx($;}a+Fr59gwAM5|$to6IkhQq5JC9&~(qR>u7jN@ztg6`cI#3 z?`%02%Ce-JT3;dOkXi*;*xy~<7z{=-q#}_@>4DN^DpraKNOKV9NxtPC`jx|1kuNST zQl9(yl~p&$rXNq+kCfq{;Oc3Cr2D6ouY~@gG7t_0gD1p-TMA~ZeLG~wy&T;QhL&D!{OBj2QLDfH^6M|YI5;`|-OkqL)5lMH z-E~U{e*4wmfBWWU?}@{%_Mbd)P=GG)*6^I*cIrM(Wi2$3KkSdjn8`*0#h|m_*+g+S zA5~`M^kV$x=$!HR>9Y^kdz*Xv+lOyoee>2+YT>|Pv+jfdZR1ffBbyWwu1XxxRp)TR)yvD^)<@J>FM#|(J}2C3t8qx@??2L zz-IiE87UM@dkpg%hPu_vQ%&Ii z5Qj@!)iGsOxi)a^r-GAt1h%3)&sUxYm zu$l}CvRA|r(^E91kfpP_>ClUn*mz1PF=D4%UXcS@I-QTjYC?ONcJ?FTk9WJTkozMEsG#mRZOOEZq3sHG;ur{_A#LmG#!m9 zFOw|G&r0Dej2&N(77d8+vUNIl=%etN&K^7`=f z)zQgfXKDA*^X*4_Y!p@+taQ7Jt4^Jr>*NI5by~yqe7`9oo~{_n$Q$VvmNuP!mL>M5 z2PfBWzWEkG9zA)!zR|<=wtq44b-NZ_U7eg9xq!y8IY0S}k5LBotjD0@(P~MWC@B-6 zM1)4IC#IdN%dK_R!0$n=&{PEY%kznSPYoPNT;FQYgFi)K&)tg3q87hekSRD`_q!7%#(zE^*Uz z@Da--;vWw+F>Wu{0xLPwo&qMX_9@RwIO<=#t0cKb#zx)K-HCwVl1)8{HU9Ci zpVcbRb^{o(743F%HFxMS#fPWm{tn?}Il(p*ju4M)D9sR?#s7{xYB8Y}Y9%9`GiGN` za6LR0SL;fxLn4g1*efeqik#Y#X*Xk&#VsyFnAUW{~(h$dd4tSn*%@RTgG*H&K7nWtjK z^uk_O(!+sC6NWJ>53dfQzw*iA5YmTeW#hq+6lqFX}f zarJfn;NYm&>u&ZoNE{N@C@LzlE_msd$<9@p%bkte%Q@S!TnC{NK6vr*lgBT={`%{` z|GPh&oy>jkeC`*&`02|JpWfaN&(9Bu@9x^_()x;J0N113G(KRwAFzlH1*khWq+wX6 zC)~`fkM343&d1*#oQ!WB=JI5Ff5$9}8Ku{U2c2&3(c`=R;6gLo-`>uo05fc^TOe9n z`FTwxu~A*Gt@R%7Z(ZLWo}A4M&JWj2P~KZR;hx9!rIjt1w7x&#T4niI8OIu{UenWLxR^AvS{4FYL|-fr6M*VMG2Pr1W615Tsm|W|Iy2VQ zcx-=BI8B-I7ZDa{@y(e81%ks1nSq&UH$|%;AjCG6lA@)#lt$yL5X&zb+e1BVuw(*U z?KcZ_?N)vzNn5)xqySFy3m#D)Gf?-5pu zZ6KqB43LABkftr_*&9ByCz$+88!?h{RkW01_R7=f0~(wotFvJN+~8jYpZJAPMk;>M zy+8mijy_h`0z7;Kt6dypGmrNci*P6gKq%xjD`g#D{8+SPoX$x^>=0v>vSc-N)Jm~3 zooM=f#t*He^}H5ArXYA#`4IId$VItsn4!uoxl!e2Y`f5--Hj*vTkAa=?hSP_CO`yf z6gVsw=FI*pL@)r~*p5lTGjikC84SAYt6c)iNEkjM1YNwNie2Z3M#zLJq zFhjvi1^5yk^Ap^2pVzXPo)8VWYF|PTt;*%$g%F7%tp7FYwYy#EE;|*_)~yvM2Cgn&jpUxeOqQ&Iy13wY_r1O4pM3t=<0rer z%kxaNS+{RVgV`P<`PF4mh}YNOyiPWxq#dxP>_$<&iSYT=?l*7FULUz|es1USes5<> z`7W<6I~(1TqXV;uR%XAs==VpqY0=P^*H*jE?~XkexwPOk74|aPjQ-on1B-iGy`=?O z_ucWESM0?vKmKfMXP+VM@X*1Bqy4?TM~`#9PNz6x&HaJa$$nW~gm$XP*> zWf-_Q4nOFF();!(^=|ErR<5b|1W(6DS|~fi2;^e11fa>KTP21HviG`26X8PxTAg;lf|9-l%`c;P9Z#f%PDH4q$}(U}38Ne!|y!!a;IF$K|w6hxI$5O?M(I%veiw>A@)oXb!UYPuRe^cRwFO(D|QNWbWtK^`^9B6&t^C!I@fA(T`XPu?--W@%}S*<)p=-|WI|E|kjTBhrj_?feF z5px@*hV13UYScqCOgy9Q18S58Vn$~?wpzh8uBLaj3c{tT28IMv7)E}TJl!NhS^Vhy z|8%EC*qETom)cX5P;bH>z5{4sqN8DLG`Kj=mITd?a%no~1*HZ4;Saw%c>QX7YyG2-pKtAUZtjOx4&03UTbtdvyH!)zY%G!E z*SBY<7m?F?5LjoBnbC%s1(%s!4Ex6}Em&UJWZ&K2v0P}J!`p0@n_KNVAdRfP+1cAQ zqv}*HqdR@5gqa$oa^{$0U=d+)HH>#@D{ilA6tQtJ8ed$H5_4aF@#W#+i5jpN%+XoS z`87s7IXO#n$jC1gOY=1F&;89zjg2!^Sz419D{Al|4y&#|44vwpv7SU>U=Badv z^?iq1#7>)=K!AR7sz&|&=wTTD8z!mD$}Z1_rUE=jBv4%KOPGXc#5u0l>FZmY+gUSe zR^M4aO;6X!G47j+Y_hs?kx+W@armWEhpw)eU@~&7W}Jo>MY?Ql<%b*+v_|;uiC+O> zlMCL#wVa?d-6~I{Mi9r#K$_qcBQlPJi#*fW(Ey3`9Jj87A_ZsO6nDw+LQSbbspMXi zB*NIdx+%bvG*MuyajI-|FD)!;m{-r>U)1!fM));q#a6%6xQd?#I_9*tEO*Q9zyvjk zJ|i(e0Eidm96r%w#4^JeCQCLME$DOvf-fTq6g-oH<0-!oSI`7}6`-&R0fj`ojfB`S8V)pZxg4-L203-8nHNV@cFhD1r{D@Z8|tS?#^Lc+1p9LnLI6j`}RKrEyl_ zst<@lXKPkwN8fV+qe+0M<)fH;awT<+jE$e~|&R$Z#lbQR$+Pne>IdO<79 zR`ib9E9_bSftIl47rmb5YjxD369&$de0X`azyFvi`K&*PG`yeR-r6!YC%4WoE}Wyd z-gWeh{kd|hbvEPSS6_ZP8ua%bZN7NH(s z_`~NfRN>*T4~HY`I${P{u|Se$n+d9R2@Nv};t}8`#aAgz&idZkSkwFbAOH88&iMH8 zM1i6-8Qjny9qLg<|~*uO=LOn zBNwHAg|=^)LX&g;R&9Gr>#cFNXcPF>6g$rhgoOp&`*alQ0riT&e;O#{|qmvFytDcmbrMd-hdE7#J;?vD9z2AFAgd21x}QU0z_tE!dzX) z4{Yn5)6fLZ+tt1jM8f@)8=xcUebQ7cRG`nMfkZaeKsdmHFlzXV+!M7#UOa_p_0`b< z;7Q-K`o9r3MJu}mx{zGpCqy@0%P`@Zz%)FXP6`~OKJu`6qt}A_nmW!vGPNN=o=k}j zAMI$&>1 zA(2@=vlh87aSY^8h*<-vn&K0EHr$wwf_N?PfvS9g5>r~xrihTHG_xG!8rz!og5bpu z(Xb6PvIc{_N-@JWxw~3xYDEo-Q4wiHhm zuy7)(Wv5wInetNYa|?sz^N_=0+5oo9tWO5B%ck!g$YNk)*#JDC#mof(QPTI&@!W&y z+5*NhtMTjpF|43pMrCBT-|3=1w0HnT7-zxS-Q73!J{peQ|FW{QZsfq+P1Umt;pF)A z=+MzFZ+CXqKK=BA$4|C$NWhh)m$sR*;=AZHXYZewU^+N*t-E^db~m91HCqXKJ@Hpg zPA^`)K7Mn2+1XxS-ywl}&gdmlEt6$;HaFp@%yb!dmi?}zQmr3e?Kq^ke{s=uX5Sha z?UuRRp-bFWi!kGzR<25sIXqE+J3AW}%GMvzuQ#{0v;(#X)8)B+B|;!gyO=Z8Zi!PE z3zHQi1FOn>;K69VV3$c?gKuv~4)Kb(^wpCKPnPD?Ucd8%q!3jKld4#?9X4*F+^6JU z9FjJhlgIbALZZPVT zrzCGS%8{s+kYpBh*;*YkySq+(sOPvb@U5tmUO92!fgVrPsyiI!G11+0m z$Ou*mN!z%{pyB|2w?8A3dLwSVrk+a((y*ZCuO;ybQ{crc`jP$A`6@S0I)JD6zO_`P zIp#d-bS%n~)vSrBBsxT+b8?56 z^pwO8?9ATa{8V1xWqrKB@RVrP!L;JHrwA!&se8#woQbSg9rrj&Ncb0=z)Gza38Tvf zXmSn;t&_7}*k+^}ts!Qu73IA znQM-_=bn4cxf+)-29;TtCl;A9H3(4y-z|mLYYB&T9GB+asX6y9m7ywE6wnhvb_+3v89(88u4dJ zTx;+-6_OzS@|U+CUq0LF^p8J$c*mJkyx+s!sz-@sNYNTC?u#hQ$dGzL%quW;RaR2a|m(19< zDv&-}@3mKEaJu7cHqupGWyLQExpo^ezwFmfKK$|vFI&P|8d<%u<0|1`>;S+r{=%M? zMEwbfV#2DO47xX?@w>Ng;LldIwO0FPG_?6HPX4?iLVyB>VE{%DQH~12nt+O2!$^>x zaV|&28bD~stL}XH%FpWC>dv2?L)`z`p>`j7l2(@*gUn(ZtxEfHu3q!@*RO#UGKsAt zLv1Rkr^DBY}zDi!4$ZOBVS<%rT2Whrf_H4--kG+xRXAr%oprjzXa zUD9;aT1XKyZ5svYkfo|8`%5t4F%uh?^RRIUHJn`9QKFxH7yYi>gqrY1km@K<_hzL) zSLFs>;h*v=Ak(%TV|3J~h~HwiELhd&R?L3%l;~gT4gf+KEQ)3^eyKp$@kBNmiV7IK>~B?6aZ`N~K;@?E z^pkf1pz8_rgH?ooMq4L^SEwolgi3Oh-c^@>cBV=p0PJ4iY3R!*aac%o0c=LKz(IS9 ztSI)oJS<0b;mm5O8h2e+539O0F$I9LXO;aj;oKnrcSxWhCRIA;jdZA#kp%-6DrL8N zVDX=K#OWnSWb1Hn(C^DdEhaubQaTs;U+|*upZlElKE)&4t?7&sJK4bl?yFaxTKV}W zBL1StlrE%$ibPjF(mOZ(OnWk}Yg~A*ka59p>3F#l-6Y>Sr7!+VtU*Nu1!fh9s`7I~ zfu$g)FzRDeWKhX=42CX>#%wzO@bQz`{j2+LyRD7w1gm9Z6)&Q^5--mzOJkSCR>?ZdM=yB_%k_q>x3%yvUEV zs1Q!@0^+IgGfvB$pgXPopG^P1R9FZr>0GYrUnIbg3z*;pNj#;9MrlL&$3MG`^kt*C(Q*3C@2PvTtw7E!gUjQE; zk-ggc^AC#Cj;{}QeCe~V={U7+-S-aKW*HJsSVwFb0nJ`Z2s7pxj%K2#GZI)_+A~CF zj-Sv2wU)7873equE>b&3jGFg%ap#3mq`I+a&~nBqcnDq$l|baV84f}Y_Jgyi8)^1K ztZ&+>6rS2d4h)h&F)UL6kerERG+CO8_7WAlLz~!9b<3gUDWn|hk!|mSit49Fd1~zl z$jeQ&IK1yCC0Ex%HRW)#P+PE45m_nzpPjs)56hAF3v8WBP)6QVQfe&6mkYnDYfr03 z4~vA2S3j$z+z`9IJF32DOK9OqWdO0S+gTM-6bhs%~bq*_aOz(CVVJYnb}|r*oQDHP}TkQI6_n^|b1UJHAB~Mz7L`;FKYv@KQRf zP0*-cxhjW(V;De1PsNeSQDpp1ndL!L+Op)uze{`dU0=1Iw<=Y{;wwFY8>#neyY*i8 zh{FPQSj>O=+u!|-fS<76ogKikVRx&s^X)fhf{k}%VYME1zcN>f63EoHxVybr!h%(2 zLPK1RO^J5)MC~$gv_|7;_AHn#w)brS-WGG*I5?n81pcg6EX2`&9#)&0ys;c<0~YbN zWr`lPU-GQYWAEtX=KSL3R#%6o$48QUXG*+UMt3R@&bSRufjaiOV}LZX8+4lRdplbH z@=w2+>XntT>NRW%To&t#Zc_%fLaV`@US}c^#KAPGCxMFQQx+g|Cjv3&&!2p` z&bR;C;dX_k>fuu@|NPCWw10l@&wqhOc`da&N>iOOx3oxTY{RfsMK!K_?5|6@+97@ z=7WfXjEI-c_V&w{FTIepcElIwk|)9?FFggYYDPxd!VT)CYb5QTr4uidNyKV7mk_t8 zlRnDML{U}>vXL-XJ0C#us{o1T--1@$CCY-v_Ewzf}u zC&YBsyR^3=tTLi#ClM2#Eag;P=F=K}`z&6ept_$D2TbU`aFL= z_sb*QtSWV`9_6VupCVOo8e)p^$iu8rN@2&sn>!#7r7JNHnPaRL68L2d1GxN648msj zVYyk1YTFAT!!KSA&Ylfwyka)1gU)^x3qmO6C>8rw^e;iWww-D=f|cup$7d?iQ({89 zz~Ad%XtEmn-K_3cQ1Swx@t`BIWg$7}42l95Xn29lPy_*+)gg)>Xc;T z>;>w(xjJ7iCa+%}bFq-0p*^^QXL1oy+||ySn%n=P;M!>zCD3 zv90^A9p#mrDn7zvrRG09$}p*rr~c+MLyA@qN7M>mRcX!eN@6k+yx=K8mjs#>^HgVSQZZPuuRJjoSO)0DVA*W$OZm}%=##eN(XB-9 zJ39@rcfbF~|1LjGw|^x39KvRzVB>wb7t3)4ks=J#Qfajm7T#1)VXYy06N*Dr7P#^u zs<-$Oy(TjmJqh6yFTCicaeFQ-Lxy67@N1-RnH*(Q&HMC=_y`A(z#EIat&tm#dv&=_ z;74(oD`T+WPQT*slHe9_&WG+`aIzWf5C<7UcBGn!U~*r(DlY;>sllO@TH?<-!5+0W zXHts_+$lBXFRy?MrJxpZ3A4tZ^qJsbeTj`HR?kle7Z>3IsPth8q0Kl-4sr>Y& zd~SluC<7!};Y_VDnbYfJX+$VI+xNG!4@&3xi<5)a{^owhtq>y8mK2&!InZ00p0zu> ztS2!I@RHc{2mxPD@08|LfY6izmWbEtNcRdBs=wr~c~pdK?~t!)65C`!!r;O7C|yPx zX`F&VAXgN~5v_hY!q>SQII0uCia2nKj*gr%vHM(GR9(Q<1{W*?QaILxTW#|Z*sLEC zuGa4jOtnA#^ybsMw`s-W=5Vlo*lTq=`+hxa7FIP#Qwo61Uhd{w_VZ>oZZ(qLDV+Ye zE47Pw+1c{`b~^d#?Wob+=?sn}%4)U_gnVf*Daa=dYRqd9shI%!f=0+rOJO~j^PDKQ zq@hncR?lR^VK^8MZ^sjv5Qisrt(9zdkqkcRJuTmhN-M-l)bkSIsOKhcvqobxS*+ju z^8Q!ff4jRYNNlgrgCWb9h>lEup*GkB@QiG(LZ_K&xt|v0B7ag#7JK84U7#9Ps&2aY zpB)9&|H?xpeEGJzsLm8BZ-`(c*#u zNoc^zGBD9@KTi9FY+t^RLz0K6%g3fCV{Bh@QacgsglasDAeW3+L zp&dKbPV3vf&s_`;q6fWl$83QMlo@Y`JDaJ@TL(QN3?e)(FnBsupL-38Q}O2k%6()2 z*FLWbF=?y5N%?}@R8(}eyp|)Zt$X>dIs)Vz$bc@Jg8hhacGPI-kvyg2r44zrJjzFT zQjA;fNgT6TEMZ7XzWer@i+As+&fmT|e)c>X{qELXzu&pK_#i@E4NFVLGh$iBx&y-O zVVmbqIs*5M+r*Q#{maX-mJyDJM@O|rQ?FE!cUT-)Y)U)aT*Ux{CO zrVpexT};1Ny3O$Rn+L+brlT9CzuVEoLT*#~0+i{k3_}GpPr%?>9#0#zDhDn~yV*YBpPC!2PVTG?oXN3f_eoLzqtEJ5buQ>gEtTjlKSR{HfWqk085dEobHz*fTN>jcbe=|}1n>K2CTdC$iBROi z&CN}x7drgXh(e2@$^vyO@4B2Z+YpBny~&$a@QAf5Zq$Ll-OR*nz(Hx;Q#l0y<(Adn zLdTqpgDwa5-P@MYj1g*ouX%FHK0CXy*~RV6tC!#0+ziFNbb70!<1>n*L~_%am~ruu z8V|IvphGDpe2b35mEj5&Tt){L{su$ma)lZ69rO_{;xHR2Fk@saU@gmC6;!oPhbobsx{I_^JI3^Y*SbRJgXK}s z?|`c6kC&96s^1y9_bnr+!D>>VA~BAXuA3F_0`s+_ezV_ecAD%}ck}sh zGCb$RDrP}Pu7}s-Tloa|>|k&6x5TP(%pnbJkwA2hCd=t;>)E%bUAeg$tun1b#H{&Q zqIvhnpH@?=Wn{9waJCPRPmYd`nyuFMCW~eyFINj90IpzQyJ<;sH5tc2NHCb^SD!wO z<_rb%moLs(Es|+~O(gO-wB#$ZW*$UR_{Dq4jCP&n8H8pVv}(Kp)?hNE%{xeVcO+VolI~S35GpMgNx!O#{4I>6RWO2dzdnbS z@KF)0K{gekv|o38rTp@BE|lwshNg;~KA*k8@AbiELh(;?<`Z{ywGt;!XjTq_`7~5t z3d!hO_@_`W+H==jfOfcuO`JDnSuHny{;&Vx`>?dB4^dTgx2&wOfzHidWQ=I<)mwbm zd04M)wkn`0LX#B;p(|UjYK^)anBDhpKQ!!ovs<4{mK9TZih0g{6@M1anz%AU}w!71_v z&6lEd921+(q=%-`R+O79HXW-`SQDB zVeae-^|#RP0^DGL?Wr)Exdgf^OcG6fk9&Xb@c5s8{277CJ&j6d(^>d8T3uKojA7+N8ebf8lWbl3REu@IbY&fje`bQ_g@BtfqjMq+?ZQGKa1*hN`+5!9%$KUm zrGLO(R6^Q}=`bXPO0u+=vzM`%5f#dzJwG)Eg}Z}RGithnv~oZCc<4FfcZ8>F(v8}t zAI{k?$D0{cK_k}x@=v3rH*1N1dE6R~N9}|5<;C^!ao5Lj*~i$a3SSwTQ8932%~WgK zt)_XmN)C?)|K-2@qjasO&z=DoITvC3@BX)6>4{FJ!BMjG#Lum6f1qh-$mj|DIG80PM4_PIz2I0Aaumf8!2q((_}G8suOI%Enn~*rgd@F;01Z_|OqX}i z5R$FF)mgOWei{=ROlkR-FNKSPohd9U_-GUQ%GW}jtnPf z@R#Ls@JvGt4*^>>m55!MS~IheAU15)8={dK+dG4U*4BFZupXTr?VX)A`&}O{@9*O- zMur;u86Hx1nj4mNU?CIn*}^arUk=n_QJattzPw8&8zgv%CNx&VGLq3OOp}@Qi@F+i zRbqiQu8Q5sLcjXz@?fM=Jq=C^AR&v@Jpxx%QKO8L4_hJ^q|pNLhyz6w{HRE|V3yLI z^*laXK91vq)w8TMtsk=Wz{dU#b$%@ejTYb6$MxpZyLZE@>;2uw@4xFGA9Yxz%QAs_ z$(o>moegUl@AL?IN*5Tr$$*{lY<TKR-DZB1g#(?6$T%4sCQ7M*!sg!c!MD3| z!l~@B-jcm=&w5*8N9`T?_#if+0R{BghYjB$5zyg7#FhW?u75#Zx7!(w$Fdm+jZYlR zeiC}vI*h5)4rs*^5@1HK(WK+Vt=mPm23;6K8Oq_$9%hW#M5Q5ITwIit=&A4;sTRDT zJvp19vk-M5)=+N&Tt39F`BRSS$~pJ)R)kyisJaOEB3t+V{GWwSua*Lg=nwa^8UL+; z24+OgSD^+4D%wT)M^QIv$nv;MW51r;2754m2>0W{H9&+4`j zrBIejqKEh?u39Q4SU21cm=}_UflL5CjD#%zbWm{|Mf1m)(5OXv=Iu zm)pKy{P3&aAkJ)xrK80dva`*rFQ7{|LuEM%26DDkWxkB$+|-`TB~~kgBpsu)yL=S@ z^}Z z3;I526tIG2%uN-lShk|I0R-Ki5QOf=MYE~1(9OH(i#-CoY7HwUc-AE}cgibf3V ziC8SQoze*1i@gI`nPY_#VrU}YL7GO>v=7av%gdY5*2(Fvu>jAd?l zH=nIK-EFyAx~Q0Z>xcEbo2zDb z!VOvMEGvGrs(Q_);x}v2U{>E|2m2>G4e8KC>uu^5NcfNiBN0@fhbiN=|9CH!|M;ja zY0TC6jpW{^FP|gYWKGzubGIb8vzVBeZ7d%?46jE>RTx(mhREob7F0S-G%&Ev0hJV7 zF9oy6jnatMA`y!WR9;0zDdLHc)a2#DY8BcGg+&p0I`Z`M^Xl`@PyX3oRFe4~AMHlt z>h?woUBb#wh;n?X_sci*Sl3j3+yeiXmzPq>6pby_m9E5pFs&@V(;nT#h7Qrt91Luk zc5gEhgBiDmB7EAcI;^sf3!0~VmQ>_yco1TF;GT2;xLgcxh#C2L9@e$d?AqMNN?!g& z!jxfLd|@=#2N9y`g$(s(TJOj1-EcXKnG9g%q7^bG%*D=~`}oP)wWR0AWu?2GvrY!+XB&<7MSQ)h!>h;%2Y%UYbF z(~usVN2+9GrRC(AONQBPiRS37U`UO1A2aEjW)*TNbqAt3jAZ%aoWcsry5XNj8(tiF z3DZS!V^Bb3Gy}f_95=mbr{&N_)YHRDV)isfHog~mZvm%%1-pj_w4dUCU3`Q!1PWTHD; zqvh?^-RRkI^Z7HLyBq^SiJ+X;knpVLK!;f>}S{p+HwR5KE;rMY&k?}jc>=9F2;>7 z5>%E63$7%xQeY(m5a=-tWrzu=OU6pD4v@+EOozNc4c~qokFG912t!z$9v{9ud(M$y zvAFgkoG7rUL3Z?^XfKFzoLl;exzY=@Q+RMftKGAF|LJ`B{rCNo(^KmLlX^?G?v6n~i&DP5?NP5=d(^yw3z+GM`zS05zanWrNGi1+^=L7U=-mnca;>=P%9% zYz03_ihDlaZ{Et@(A;b5hx~egl<^doAir1(H%7Ta`T?1e!Y{A^(WHld=ZJa{+gPRc zA$cE%RbUnh@Rw^eAEXA4g`U1XE4H8_Sa$vO5%T!zfNQ;TqdHel%C$z7Bqs@>Zb${o z85smT!Wds(`}!+46^@juDhy_)r^f+Wrl8Jw8bZtwe$?#(#w?OPe0={F(LvBblYuEq z<`Bct&ld0*R*d|zh5A?AfWV;JZK0HgZ);imF`dWCBn4AiL~7K{NAA(7D?!8el3NH1 z_v`a2u@|uQR!0Uw{@FbG?)ZcX94KDdcbSJj^-^PzWpxI1jSYXL;2e4dn_-UJ)I&nc zfWm8bzN~HSH4lzn>{&ez#yC|OIIu=afKYHOFo{IM$e5zBv$>sKGDebmm=@sTiA7W^ zFa)<1V}4N>3jlEmBFF%uA!%W0QFY3c zF~kr0II!@61V2T(B}CS|&qO@Se;C4~l)jh!*m>mdL6yBx9yhb`=o-5V*sqc2 zc73vSYBu8vm<>V}h@JvLz5-3TAeHLebn{q>=l1cWmc5*d3G z$xi^YnwP+6_(DR4BN7TM3Q!_u1vk;rio=5xgo$KPwFR7Tc{!A6s3Mk&I0zXTGtpEb zy3D(@r(h6#16*iAFJ73AZ!ga;mh;VVzy9LcV9+H-ZOW1jl`B)bHmC*Ej5;&}W*V3v z^ozl<401`W(;N<$qw$il^z8Ym2rB^$6dB*z<^b}~c*Hg0g8>C+XUF5KD{kxNnsN(d zx|*;$emPr>efjTY24LNwX3ZzVOV*cer`PBo$|*CtneXkM4~|Y40qJdaqV0EkSZ;J< z-!7rgoBlz2#|Ddc4CR@GqAxTN55PGj%2g9Fa(7gw{<)v&%@?+gxQ_oIc` zaMwTThg2;Gst7${1WQa#B?6V?Hu!sov9H?KJR5kd}w<_$ko zQ{*4|LjCy;zVbY%iJU2w1ZC9$jGr&_*G~nwsLn1R$l-$A6d_F~^~h@}w|wiT!wU{~ z=((yRtB?&9nDYL4cepa+jCuot4I@?I88JQ| zmRQ!qH=euUDem;G+Wjnz3d$-_05PcXQ0z|eJ~?K_gJN1M&GhU(+0&DdcqlAjEiUyPyRF^j51@(luKsg2@-3_`ptp(ua z@JbXm-^=$k8s1zCiS=)uH@|!JoEG`8nNsR#pyn8`N}yLg!beJ%pCvNZfpMPYYHn>kF@_cQ*c-DILjnQlO^5SAT8}oEqFXlJbpPKoM(DlM)fM{osR&6;O8Z(dkbg<^z4}8)ttcfw}g^PIXVlqf) zTrVyQq=bGNcneGw5%$yoYZ$aD1ne?cjD}b6U_kQFWguTFbs1jvZK3Df&zpEZNBAmL zJe?IC#AU|p^2_1H>N>Bb*r%UQ*YR43PU>L4@(NPHDU~p(-br2MEAONzd{h3qr1?fe zDMy&BD6{TkphCoj?4!)MW%jFwcU(~BliBcYdb9Vi`t9rEzxnO2U%!6!@#0*;;bXS) z*+gEFtoU+JOL9e2h01dT9YroYQh%yz&;br-#X5NGKA|h`e-Xh#3kolN29rlV zQB=hPE_{m_9D$Kt{K(%}z;n|d`Z>}OHAK~2+hz4(A}nX~fTOIuld~=Q`u#RzUm%o$ z1DrlLyFeiHiac_79B&N?T&mxtOX`bpDm>@S5NW8<8&Mu3t&4W4^oU(BLvS=rMg#yS zx(nh%YbwilsS(6=UL+x^g7PrMaZPi`iAmhVju4R>45Tr!|Aa*R%w8Ds56`9(1gqaZ zjr^@KpeERluF$p_6qj}RO&(^0-rlR{7QqSPg+*}Y{}7Zf!c^R6v0OE@sU>K70!03# zIqtwf{4Nr16q^8WjYG2S002M$Nkl|^+E~iDv&TtF{*BOF-Fm*ut*z_$!)0-;Z-#i4$nyLC_rN!NHPHuqf-7^ zweR9#Fq@8svVc81u6_IR*fu3(Q`Fd`mw^MQ+XzkM_pMkB^EDkh3SCeJK`t^U_Vc;1yhB_7`yJ*)AfP}*j<;a*I?`XVmuwqT8$wi18KZj-PLxb`0C%SZmh`N zj#nMq`0}nq8Pv2w$uOp!{vkst-X0|VTC8jP+if-O*M_&2`#v5%d%>xTKMy-$Y(EY? zi@Hv;i^)JosRC%QIMqfGD9|O6k#}e!gEFWX+atk>bT8GqrCyW@q$vh!xr{y(y1M6fhRt0$2AaLsXy?Yjc@eCY~HngswB$~#Pn{_&mbW& z_l#F@vO#VPAaPHE3K|Bq=|pDW_vD3h22Df>lO{Vns`l&ls7ja1fg==Wk&5KHxV{zR zcs-h252wS~5TjWOu@^|A_x;^t8%L@lX~W4b_Pj8fLdySvHOemou%|4BMXm)2)yR?Q zIGY29x3oU3Ea+i@N$N6i*KdDz4RwhBfyGPD)!?mAsK3DA756|lS-v7ANaxZ$ueiq7 znSm5OH*|OoZgi$ZzoKg+U*;&g+fYXPpdaUUpy*%MS#c^V9H;W>?te76G(D3vt>&%PXaA=vj# ztO5~F0PAO73Lrp)&tR-A#ORX<#sfYH!@*JXJs~OgD;X3R%Qpy_VZ`Y zw(eQI`Tu0QVGaUZ%XT03?M3;+cSo$YGy9vAQ8|+l;~VcCecRa+OV;mhZin;pj~B-$ zTPG)Hv@p`S)$FWWoAK~wIAZ77>2gijsTW)ugfUJ~1uX6?<_|~5t)0c@c63|Y-*jw2 zHXdJm9Je}$`_f$Ua?p^icr_stN9OHm3dltahF~L4bASrG=mlx%&5C9UUzBNMz6&No z%3#pk2CR(|#7*XJPTfj$?t4`IXYjQlIy|Ny+1+Ty+69v10x!>+0LRUYET#CJ@8ndUa+AcAv@OO&V#sd6#z4Z<&-dpS(Itx+@%;J{Nh9~}L651PF@aDs zF{wmY260I9m|AGu3qCL+ga`vAFtxcbti^5_l*Fp|;8ExO+EgBS>g3hWv1b(EUnoAopD3-QqC(;%2iUsjHX;PuB zf-VWu&%Siq$F1)D3)|k+X7lCSx9|S&c6j;Wk7uv{#yYh~^X}mZhvLcVjtdS$nf62i z=D!>nWzzo)KWIS7{^%OrJ_-(XpK=@p=SipYgxmocWEQhHiqFWXz4_y5p@iVwg@cmJ ztYIXGPf|kf_6)P`Cr8B6-p-=4!RLz$5%q{BwP9a1kST;S{q<}-n~V`DR}FJgyQe@w zjN>?h?`RQW68%dgWjEdr?{EI{*Uj&~IqtUO>b4tO8+^jCCb3CUW3^BZqs^uyIqqCD^Y=T5M#2maBGHh;#=WaQ8F^`?r|}&I;_NuvvGSK9J$Kb(#a zPfs{J{MUbY+Z*h>d4GL$dS(t{b<*0`$!YiPFXO=p4O4z(Vy|}C8Hj)symfPaJ-nHm zo;;&o*)4dhw>ujzuCC_1L^`dujcEBjKr-W?+^;lpst_&ID$R5babJ5pD(Z84s zFJFYB@u+i-D3#tS7<6_gh$-}6od{kuVr70r5xXlx?3XGRT)%htBs5+auk8}a)r z3kclk?$?XKK~ucS&H20K`0{tZdSTu(Xz%W`>T)fG&PSwHew1(Vj%O~^3pC}|Fc5I5F06^qJ6|IqV z6|GoS%!Y>Ms{ou*V!1I2WHqILKlUwj6h(`!OmUFCObjLYwZ2>4jz;SxPsSaY_W4c+ zAwtdwGm-}zlogPj4JkY&6xd#g39W3)KHaj>scAT86I+tV4P-N@y-ugoZ8S!o-hcnA zS0CRE|KeAj!$JLT|Ms`b$(7u*V45z`4FFFwwHa77%Ji`^YZvbDD9s4zCo;8SgSaAO zQ<&t{fId3Ch8E>AZ>HF&y}9XyOo(zsaDxckmghyFf94+~QEq;HN~^Rh{8n*V07q~Y zH9OUr4+8*#i1l12m`f1=wgQ6HB0f{>)FkZ77bo9;`*J-W(=mw?U5RbX2g>H+SL157 z84Z)YzS(Y<9Sw-oxy-W27|3m>aC<8!xEdjM>$_hak(G!2PF8$33+WH7K3SNOk|lc* zVH&F|vKLBnZmO4|u4uuS436t9LAvNfXv=VFbA)+qzp-3TLQ!ITnp@^1?Zao&xc9Em zHYJIn7Ou7M_y7GT{#qx;r*tvVoAtd5DZ_sM`;R}o;z0oW4}79mi?!L6jdcPjKw(%T zJY!eJOpTVw%j5RR(Rw-_3TJu#>KVGRnS;(+X(8b#E_+cQp(cwAA?pB~EDh)Bni3<} zki2tcdEk;!~^eNK2M1i9mWef z(&OpT=URMNz2pABJXj{#Xb58Rzj~8O=e6=lV4vxjRpWN6PJO6v1@mvO-nVxj{`$LT zc5b#ykUb`(U@Obb=|4$_f8;oX-<#WO`Gax3?bowD8`2E#tBJTQQ-FIhMIxm`u34?) zxQ$YnIK#m88~YU+UbUmM3N2fNour-eDkD8*Pc5Al)+dzidKR!sJ+lql5-bsfpMHe&Uyq`fM7r*1bhVl z5$vBCCN%~`RqZs@j7O1hX=MK82S8+I!+|di)+Eo3Y$!*n&yU0Fn~RU;54~>v99 z8P|Sqz|_X^%4|bcGR+h+(bzjMX^YJz;R?W#rQnRXnbqD62Do5vX{~u-Fxlf$dGRg< zP8N%k!C+-GcAeSXIz4R(b2sS}cxM|><@l)}b9;#s>$Raxz25(Fe)Z{Gs&MpA@uI=s z+)P@HfvLcfKf!vdffy5j$8Z@M(*~=giBzoHT2w30Wshb&8QMzVyxw?0pWIR-*L#VAaSRMe8!%2CR%YN^P^jT|02+e4TS9Kedb z@{=x7DUljny^}x%&NuPL+SytRFK3I}?#||~fAj63EbKdX@JYf_^HN_1pKbF;N#sbb z%j@gO*p?fTt_|vvR%GrO3U@*p1RjFQgjAet)=UeZ7@PSfTYP$oaV+{vVG{5MeLPWo zCNh}d$Fwo9iCaF!*#z+B=8cVy=uqE(^MgBcTjW|mEiXX=9ok^(BR4D#YBP8-V3F() z5u2kdw|jQ1%V4K)JflWIvCfG!FS<}zJW)@WO;J0|b{Fn7B3$Fh(w8yT;IU6POiU$B zXD3d?adp|gJ3455^ZJ|J$K`+dhd(40RW|{6CR5bSVxm+OvQc8^EDkTdPx{QIt#K%yf8x+8%$U=M^#02;ri()OopdnJ7ZtwOR2}H zC{UqY1x7kyWXR2kr})j)`G>`9$W(dQZPPfVBQRXu-dxl(h^DWR?orI?;Sq(S;i@4* zxY^P|Tg=Kl0B(j5!9J789ru4deRaBhb|z@+aIe`AG`U_g@hAS-YD+lNfNXVbx(HLy zKKu+N7KVa!fiTiVurW1uCiDBtTWbaU*SC7jt();of&w-RD!5s#vER#AV}q#440eCC zh4AD4cB|bK=D^ZnnTNFx<jR4Cu*pH57*AbesF&R`x9p zNgg1w-u3P1`eOR{`0m;3Z{(Dz^Yh>|lBsEIDqxFTF@|C@_QPI=;+%4I_0o38zK zp#{FFyVC52wF- zefF>#m)bZh#@C0KPb7O>uMX$4rCczk0X+fLM<<@LCMByAqi4DSA(`E4`C zJ2G;aM`$s4AT@Wqs!f{W8ZA&kGwUtbh%JXPu0WqOVg8~wYw_#X-<+JB=rs*X59J9* zdXA_5WGawp-F~OzX>8HFSxMZa+nCFP-0asgwD~4_UT7ycAfxI{eT4d`A!U{lBw-b< zok6>Q1>%T`TzaJI(OpGC)XcpmSzDeR&}R>tQmM|ae-UfGT)zCnFQ1fg{QNt~Lb=&I zB=$d;L$75mjuJ0UU!Q`B@&_@wj%X^*#vu;UHi|e|5ULhEL0gF8T4}@~^fU-9pu3Q6 z2(fq+hJYWpr0Ave9dzU{WmI3vejyhdrwPhZa;nQOIXC&0xy3%wcE-DFa^2ws9AROS zcbENhA4>@IUfGc$92*!F35?Mm?&mk$>izrszx%7J%lF+*OUx5K1K!v$H3^r{VYBiP zRocWd-VW(xD84!j%SP*D-mKNpLnai3M*<%<)`+^NwWFu^3+Y1&A!{0;Sdzy%GOUEA zBC(iKzd8zl|HUbRg;A*@WUEoNv`j5eR0y*9FaAhbu~^Oi>8B4T$J^h(e!(`%jBN}D zSWd)I>OwIF$c&JrmBC`UvIim$+?~ZVlODn3TeW-abA4f>uLL0W`xc%0M<<8;Vbw5O z+%Vn1x6v+2A@W-ZB(UYvEDU9L>fim^uFG<<)?GJt7 z2Olq{`}^0DgHtgwfQcE|emFepOs2Eptz|AO((3KD@Xm5}V;?p~A&j6OjL5^`)S6io zF|)-Uth0=xAWb``O)L^nl<{_{TA;_>7sTr`wp6kjhRmFM711F-STpJa`((WR+ zsHAz-5pe+UMJElf6d~&lWyyDW-M^HS ztWky!=>uffQ@gyT%s7HArW4_|f*@Om%^zN!?LEwek%)OASBwnNOt&Qp;mWji$3yRS zsKn!buhHsCI6x~k4+oNB1JvfwiP0pz6F)Aal6v(yJ&j(#IsvlLR4BNil>!eG6Ay6p zp#=RVT)>P?Ogi_>!fviEPmT_sKYwYeW1(X@$yUr z*^_+J@@FwtFeZXh=I134rGlDw9Lh8*huz|Dnj=rS{qEX*3Ga|P1$~kj>*AyIsCQcg zX8|Rhhwe?1Hy)Ply4|5^DxKec_l%X`4La=KB>taA%4WDXI9I? ze)q>8|C@OtpYy|mcIWWenq-k|wwRx3qoEi~Sa&Qs(N zA5RAD-~HteHu{$RUxc1?L1L(QX21i2gHA2VfEH#iSbyqes4z!i6RS@**Aoc@&@@Vc zuS@|lO5SI8F_%SXenCxzB5fmNH5EzmOQB;GXa!bZ9~GLuzEiG2EPxCSpCIhY&D=nl zn!*0c^v{JLqcZ(&E$eo(KWK5HXg2KsAV>y}1er*pd>2-sYV(3cncQ?6d|jYEArJB< zVJqtGqeZx9&-U8~txiucquG~yhT~|#wDav%7=@vRQ?gad6=x4q5VQLX$x%5%qxXi)%W23>G~R`E`6jHIM)R!1R~RR1wgE_Twyn%Tl#%9E9ncO! z&6#1Xj2kYsDHc^-^A<>2z0J&&fuE4r+t6T3d$(7Y&i!83hZk2D_5Pq_$dT!% zXr`$qqRX6@QX@{2v#YIkIIY>@k@7pY2F-R>5o?9dad`n-~MW}{OP~`{tdK*Ycv<9OK{q&2P*Yc^jB3@beHKD>6vHipagW0Fqg}|M)5& z3zt9XHTGM^Oeh--<;mkoA<)2zTZCJyw)Ly;zwz(W`yZb_@9}3c8HH|A{b@weEW>67 zgNPxseGN`G3#h+9n+fRr(+wTK$gBkN2$YE%5T8$4~Ynd{Jd<|#A?4^ zd;HyBeSf!^lTB;hv{UA6S1xBA&!q) zr_WDqF074f4(Snpcl#}Ly2t#qUQMVj)O!DsxZ>JF#ltz{;w_`Q3Mzd`{ zn%-86$JO;{K@WTJ>h<#%uim_WcXa%$*&MAGV_^*XchKMkGy~ZA!gBF8uXezYj*;%q zO3-e%_+Pn0FRL{$hx{W<#u>xwjSfFXmO4GU9`ZM^5Zc7XnE~P(UF3f0OKg!d@FnuF z&Y6XUWOr|U%QC$B^y&QY_%!7<>ogMI(6th@zg{+NPgK;YN=r!eTV`!EC0MJFm4Y3a z<&~rQ`DbT-MIORj<`LoC9N`an^kbu}*rwbpLKf}%p^&hVBh!_NH8U@EBSQA`0k=%x z`PkjkewQgzw`F*!*KcmF(2>_jFoV6~lvP@oGdCg0gyyFw-HV^!ttS^}r{6X9cW+mA za^`slhCYa9Y0Sy%nPpof?d~@YOoiaM)9v#xGEd2D%fBc9vAh6@wo*q~n6gA`Y)P|1 zG`t)*%3d$8NtfhqjVYEf!ILD>_L`1om^MXZIfC{_cA)!%P68i!@C*(QN0af#^G_^I z!uYsUhuMqqA@%w=4Snni6EuZz$TE)qIL?=SI7aKa5mhr2j92ROH zL7CdZ-=bnnBdgVbN+~x6FBVx(XbF7D@-Yb}(Uh(=4Jbdpd0%gJ5Bdj_;mz~I_WD;Z ze|-N-@9aAfV$8FwuqnT&0bgn?)xvj#X!XxKv{U!XJ3!=u5% z{@LA>4?;(Yi`hoNsG&qp&yEgX@DtkHG4}J;CS1-=JAcokU^QvAxteD3{_gFE*RQ{u zPMC=>9ulg?Ey4?ZL`tHD(#Z5E4K4tevZ@`kFH- z3tz`%n|;{0ktMGP*^ocs8W)^QR-S}-@g1UV!e+ozWNUP!Qa8+^&X@}{E<7WhPNp;X zS)ydKgvC_U)||@!@_A28x^3V`lTUO&y1Z_|IzfueR7x9*7rWlvXDW*J<&7|1le>95 zo?KmVJlkrElznCiqmSA*kJgj58Q0jVz_M6P0TI@F_jd+FM7<+ztM!Jx<$P@m5>qnW z3ZiwKZW4zl`VTMkhyV7+pRTUA{^GCBu!8~6RV!dvAUmUXRAB`gZHZGi+#c zFby>b7(Pt%j1b#gguq$qc({+^mh^_4a0AHtUiZM}xLfUGnZNcveYz69%l1%QNFO>e zKmj610(KOS^gjDXY`RTBUptoVI#OFcduF4XPnTnw?)LrZ?x9@aGSV>h?+XOi9Xc(k zk~NTGa!11%?jpgkp~5rO2p<-gS@?r!=Zb!U&(8<^`ldRnd)~o8m{j>h9@-DoqOhhPOlRp->u8zY7fKAE%5Mq_46NpKIol1 z?%C5&UeTI}YKjCk(6D8aXheoDizVa91n9cGz8%|*Uh+q#vFan#m8EIXbhd3@BO##% z(Qok8cyh}IZ_Kn)`Q379d7CCn6yo_;FQ01;jIuKrQU%LMP54mTvn7PuV{-8ju_UQV zxFs=0q0B8&%Hl?Hsv?J7*?019v2(vj4tJA%j(#A(%W9`EQXWI_iOX|_U>PNd-m$6TenQHbw3?X#cR&jk5}W(&!28T z+%6~gJ8wQ+$hS6L-@p5K`|f-w%PU_JZL!MDa4QPcB3_fT<<|0XJ*E-=hkyT@_3Bot zX2fiIxmadrcyoNI^Nh%`%tmpbkTcm~D}MiFNvqHv!=ucU;2u`k-Z}sD(eC!N=_)g3 zGEy4A7<9MyxKB$KBw@MXV7@ftK`;%$ftc>a^B0T;&Bi|4C0hpKNcbv*SrQ0u$(|ub z%jifw`P>*FBo&~P7O&GBcW>FxO^?noyC!K{Zw%4T!BTHTOK zyNnX|202mYyoQth0-}&!7PM+D&P?$c+R#Jkq?;dqygk2KWB2cW-T&=hzaAVPY__F! z9QOv+01z^FV1oo^gf!~$Lg4rPoK?<<0%BRmLs^o{>i(T=(1@K{_N1Aw?mu3Q-+r9j z%$N0p`m=8j4~S$An0yJh9++Pji?P|7?A@60@_KVS84E!`o7)ezY>0>fjOg`(NV1*d zT2|=X&Y-coUrQ{obWdV`Y427|Dbf8t*4aa8i{$*#s)P%Hfb{&h?S6$x?3c68j!(m8 ztj=T$j34;z)R`8C)L@>=k6TBBV*}5K8~KV--?Oi=fD4@uA43W5h^q_S7=a8cl&lnk z>{(iv+$rNlc)=hkfUs~KHYMz+tGN^YEdL@vm1lBHE!C?hRoHyDurZn7c^Dh2Hl!PQ z4|K70H1>y+k=iL1n3IhR1>^OIAWbnErDfzr23X|HfB3`4>%n2qx;l#*s}SjIAz3@p z0>b^kq>=1eO*yIyxK8k)NpXCeNGfTiQi`EoyNyL;dh4Lij)IwEf&oaKM;sCx8kdn> z(Iqj7(a3N~E@Yt4M1PD?v9kIsKlzKxa{(E#ODFHuJx@TS)|D=Pb5FubWkj!7&F69I z%$5ZC)L=~QCBZEhWwD-~6eDGUC%)DHD9ah^Ot*lM)<+6gDq%D#IN#mDQQX7rbiz@A z8!7xzbm=d^VYw+{8uHRkbOVKkcd+t*iuDBK@={d4%pm+s(;zYml7{Vxm~ZdJaNK0% z-YmGs^cy?3lUvn1?AU&;Vbs=e4a1ksR#qe{2}|?FRD78f2`nhB=(bt*QW+r@L6O!W zMI&;p^Bm{m}C)w}n&Z0uQNA=uW9ZAOyGpjkV6{exUT)5-1Sch=7YOTj(zk4ZkE$vB`=^?MAZ=8uDOygzBw= zMfF{>{F{JeW)c~S!;xhC!{NW+B^3lqhOuyt(xW-M70d-{g)#%jKr)AGtYeKzYpqk! zMh&h0zY5OTNm-SY#^`OW6ubUsc8+k2-k`jW%Kzb$3c5b0c*x45`wU0tiGBa!VmRR+vPHZe9UT*$TCV*WOC-mRaZY9v3HqptThv=a1`fKO zWn231&iXrRKgmJEeGyTC+qUJnh`H#?A!L#W^t6Pj-L*zKtD)6mXQ1jZ?b+F=F6FuS zGZ}6V2WRw_o9nBt7@*+$A*PVHRV6<*2({|l!|DC|H=oew@v~>PDI+dsQ@g2}utp{! zp`v4k&B@o85a1s&*Z_(O=_RmN3vh>K$`RuFeBoLb93i>S_YH%t>0&^U5{sJS3$8Qx zmd|=>ONVvW%pfAnOsEuNnja^XEXHb7+C|=H))UGh%~5TkJv529zSLNn!>SWrEzR8C z{lEU>Yevt-=(4%{`0LltgLJAyWK7x=j*mk)#mnNZPL_i)J<9d2+Pf&zWD zxq4^2-cDwjtnv}Ec4{c#i1^8987oDc>dkBnmZ_HBku|Fz)dFtz3R43b5$$U%TC zR-Ui#%br3z0+Aa#4{cM1)$Cz4yZrE$#iKvyx0+(nvk#OpWwcmb-Awp>%vSfaRosZV zPD0jfF>6}6Q$d`xEO684V>B_Ju#7H_ozz|}N2Ipp3O-w}zk4OsQ)2G{Ad0KR+p(!Y zG=ot(13Q`h`InL3{~>_S#0O}4Sb&|O(Dr3Fw?h*kG))oqNLdKIpw#eaN)gN`UxCJZ z8kUc_yC?^hBSQA~IUlH{Az9sVxrLO_aKv&Ulay8P=UlF_2~|MgTXU~T*=gF@{_0A; zD`4+*S!T11H3lG3rbd(?|yZ1 ze0G${b)u2^t6L8Mi5NaYkR&M4Jm<1czVbc`TWS*hHESU#N+6GM_@GCN$A9lXUA}ui z8qYTN$B+f_L|84fL95;rs5*r(#k%Y_3{<*rds8M?yaS}knzo^4)GS%s2ch^<8u88D zn}A`GK^2RV7M!pG;(QDjJ`IWEYyD@e*xIJw%_hv>**qz%0ySBZg?2G%;R1z)O~5?r z1ckKLAH=|;NhU*Z#}$X#4}6kAVPyml81X1Hq6R}*eM+q(euX^}WCB89Rn7?1PC(V&GZ;>8BIgnM*VeTY2Oe89F|X*-%i zR|fCUuZk!qigWm>ICU`0kze{YT}(}xqaZ4)I*K-t4}->7vP|^2ceqo1C6^)*&og%; zZ(6Y;vnLQ(#$i2)-T|dLN1{cvR?rXn9nKnE3QzgE$IM%GvUh{BZNUtaJ738X(|@)d~qNeT+&_i$qmwMK`e7arCU z68^x=kn;hn8<3a?18^ax$bm?OfID>qQ>=AVBTlV|mYN-dND{;d6SP@$n6uz6OrUDM zSEl{+Q>vDgC`Q<=^>{hHsy(j1`R3)xpifU^LS5X%B?B-8Qwmhi^0x-Z={)8ntOA>y zpxI|wQ^{jorMCu73E58it?B5(rX$dGb}SqJ;j=TbY&1Z zIMF3Ydje>B4M>5sFHoUi2dha2JEnBEG}X%*mT@ljpxE!XzWw&3)9)3P{9(Qr%WPe5 z?&4b$KjFe$Q?)dSt ztc!t8-E1wH8|5Huqg4bKMac-K8?S3MAtb;iPWNg4`84GNzM{gha+A^kjU^G^RD(QK8ld^9FOkj5 zCGlD{!=3UlA66I6vc9a7HuT}XXRrlh8YOOJt=xX7G5`bpR3A1$Fuy!Rcp#h;wpo-fy}G z2Uj=Jp7-oPE|S7lb*u-0X2fUtvJ6-TEt>piV37*Ma+ra+pvdrCQ>^qXnuj{0VA6@` zeX~YwZ#o&CwhrWIGW#10dXuYpDLa5=n~Z=~3V~RS9HHtAY*urSCYS!IUXsJQPF{*g zWSNqw9)bW<>-Ke!GJr$TgaMLXBxHP_rFJpixtsJlt-t!sUtAAIquKm^cW?EuM^&V* z3{GE@Q1gqc>FSo;i!{4Wc8LDr>2kBE)ejn+fG8efA{L; z%cFRPSRf5w2eaUwX+spo5Iy#The)*wJ?RWhm=bEevycNCD!5XnF`YuE0hFT%Nfw*x z;6_G@fE3tP6u*TTAt82clr3Bpcx_aTKE06TjVR28@Iri!{7{&i|LReDl&gqIh+|rv z@3)Qa5%S4#>-dmrPwCsxyo!k;&Y|2~dfj7z;w(Z_8iCc%@nke!bN^c{;`C}BEbiQh zik8USo7)R^{^#qiDz>`4Bzjt4Lc50^u#ISpA;eq3XM> z@%8Qb_3gpI^wo<~c@V&RGM|VsGg$>jHgh|-6@y<;$KQ_(2mcL+w1n|^R1=ub{r;o$ zhF6=#YKXS2WZ<)fEx|&TQ0$ASqme}r#+h`DZg=d6-f1^)M`o}SRMKi3S;E0NG#(Js zZGi9vLx(=XmyRfb1a^VbG9qKLVEBJ}DCf&F*VqRUAPc%*~WN4*fIU6Af zDIt8MF}1}D`Vf2-cqm7Eg8T}41u#zTmo62a3gEWH^QFU~xAZ)u_vMhmyBNbbvMh`3 z&*!wKSTv%_K7l*d8dAC-M|Gi>6bH%$&$K5U&rLKK92G@c+ian1yEB%x9f4KEuCx!e ztzCOD{ro}pWtOj`2W;^3*xodDYSajo6H~r77G<F8KPytai5~ab!t>gX^@AH>)QkJ& zgPnu;@Yt_V66mn_a6Bhqr2^Wu86x)-Mg#NU+V;l}Z+EvAN5}1NUq3su^MTDvmgB{8 z$|wu26@Im)98ozcU?Y?2R=Hy?QoZ=~IYuYigXtpI)1#w-g_SIMl_ZKqc?oqzY-i?~ zBcvcII13K@ymKrrwPM>({$Dc2+<&K5{FloYfo9QrQvi6P=+9!`wSpq9Y4iM2XI!yt ziZ!!<@#@vFWX*IALdJI{#Z+M$D6!aV?@dQj;XR`f;gGbgb~zgiYQO&V_qq%Ph|Xb>m^jfU#Ax6112s?7cl- zB%wi1utKk6{Xm8szPFs{z`%TwHx*BS0+pvzfp#EIpf2@p#Qkj zc&J@Z=F>mC{di!P?*qv#I;b(r9b4-_*{Gg|LSmsU4j92B=+deX*dwKZJUlt=@6-(7 zTP#RiI#t2{rSMz^q(HFMsj*D?JErONc0Gg2>N&aZ0H zg!$42d`UAX0P_M7#c|1G2?@F;Ux@OwG(t9wrF2j#EXH{`YsUd|K`HTDs&-C5D#|MZ z2@FR058~)iyG(1~z}1L0NL>+S${C>FIM}|M?cUi)b$j2+o|bOrGDSb~hg5#l5I!ti zc90NRilx^?4+#-oFKvSub4j7jP!^*`TC2_yZkvjuy@>IoMX#n;-MxpCINt1Bj3?U- z*4VwtVn$~*Mno1SLxN#&%7S?&iR@7g|bkH)vZfr(N)Yn4S%q z-@H0~@%-54dv}`!itt3OT2h9a;3;@0p!$rV{MFJgKl8F?mv>@h&1^&E*e|YsXJ==B z{PE}3L5GEt-bA6}K&Ou>77C#|bBOfTdW(brhDeSgxX=h0TebgSAmyhOrIKQD0LJP^ z)sAp#2FMsIlt)$BzP~z`{Ti|!PL2od7@A3Kq?ZIdp+541^Z(u5&;R}Rmc?0&u}-hI zclPY`#j~?szw`6YKL$S`FYaPKfSk-QB(LJk^sv~-f|i`i4-(5-fH2|(t7NP31q(*3 zWO$ID2d36yKv)qpx!rFKj*t3>15xb8NGP`t^nT}ne71r#xxL{CK@mjqdP9W_Pv=0_ zDB8T}QLUhd_pq5snb5~pzu)X04COPtxwxJ!EUq8!c1Tau(yV7+O!lY98rAE*`An9- z;{s7H^H*vRgpl2>R+r_swtIVZySludA{?dz{YY$LS)YbjjG_?Wu*q)R5OqJ^I5o=h za=N*?-LyJG-W+AYo#cqoBz+L_6X@uXkMcKCFbG8wdxiGP?I7lf-jGirKQD(5pU|C$ zd9$Jgm!%B%qLQcBR1wxGeE&mW4W&qzoo{2xe$4g+SN5bny+mO)whIu z@KBC|T~aDGeCf;u6ljsOaw~2YX<(*w^#7c#IK|%2c%2DdKoEnEcE*UCO&8Q8ZpRqL ze_IK~oRBrPtW-G@3||S^N(h=*&DYVZ>&1nDY+`cuw&C>lc64<3I&wcwKb+U1XN8NQ zFh_XA5skX^+%*a>*l;fXm|S2=IzJ-BQ?4KGdxzp?e$+USFJsy6b@fMZISW@nUyP(d z2C~8<^592feX5`m?om3E%_OE#wCo!~F|?KYKFvO+t#)fTyXI*sg-W~8CTrQm>-Y1< zhIcKI@X&f7=!dn(dV7B#4eK0VC28e0cg8dz%$&;*M+mTDY5?su64&^Hbw!sGCy`AV zJTxZe-|3>h#Sy2Bb*mXjB3;%#+p%4JxL9rOn+Lu6LC^Np%o|51owsk_o2ls~Aea-I zz94Q?#f;u;B{CyIcAJ|7JKkhCyncDw;X;3WXbH#4kD75ECAVI2b7TZufI*0_kfWv> zI0BQ5d1)gufFhq3A?s#1q<}0mUU;h8IY2ptERQV=$*{|jH}n%dqjL6$$%(ltH6}*8 za6%+?h*dd?ubeP+Q+E(KqDY;kiD{gdo|>pq!={xiI_AU8)rB-t2d!PfqwCd}r%lMl zf>)wO9F`5@I;NQ?&jv5}yY?l}c@%rdCh+0oFTH+S3rUgGkJLjItYB>V)K%(Xrh^!V z{$X7gi;0K}?l!2!H_Qkua}E0>N+)NLrSFICoy(T%KR{ZI|AoUKmzs^8A3cm-=B3`KlzC zt5n)(u(T)CpqpQMT2WUJ?P)oxi|U26RzbK9_h$y4!wYZ}ItU>>L9Dt^j_N_r8N1OZ zomno*`xRy9bH|sW4D#u1DYkl4ofYUS$L|0B?@l@APDeL?@xyBgo7iL}^_fhk1{3R= zd{itwa>NLDt}iv%P)x3LYY~Ykh;v!{ehe*+~J?%E#UlcVf@=IpbAXWeNcF#z*mI zdg^W@P`FTKnpogfpd5Lo^ROsGW4Ak?FQD$QWx-sG5tLJaCeA#t66`ajWSH>;d~tgO zyDn1~Qk~}o>@l?x>Y5=wMB)b?5BaXdLs5?|nZjDVU(R|d9nsPP$ z?f2h`ll^pgGvk9$Z}Fd$`;7HZPAhBg)@lGA(AAaD5K!50N#+FB69PY<*#PlDVH_1Y zt-Wq@mp`po5NhnVuTHHA&u2p(M_K$gEJcihK`>h+kx8Gl@CmicDi?1g+x|&nTT)CQ z-8c_%(FY?GQTQSzWd)h9CKu(sNK%rFi_dgA3+V&`&_IU|3Hd+t#+uJ3eq{_1)X#T< zV={A(_--ry7&2&4gG0%c=m}0~C>qT$BC3Ucg6cL8?Pg~>9KL`1lgW)4x!qpH%SvZx zWRHN&x+h&s?pwtU%9ONxTh#g(B%=zTNCFsOqMW6V(mNR8+$QnE z*5#8FSSm@6%Bp}nQpd_-BtN8UFW>88DLR1yfM#w{ruJGG)3V#ZO&n`*&cqyFr|@A^ zjgVx9mhg7E>PpD~fx)!iBLY2&CiV&u5prpRSD{n6F_*1Yv&ZDWk@|`r$2nOc8ehFx z%BM=le&8D96&?kE4^F_Wr(xYFBRSHk#Elj)t?JZA))r+S3S%}df9B3vhX;XGONls$UF<`S*Z#P~HT4UM2q*mD4Ufj)Rw^MXiYuSZBHjaIn zZ)BCg;RKDlI=y7x&G2TOH@UURol?bpVkfZrW@$BAY{Kcuv7Vv-EY5Id+!DuPk%V%b zgnLw(#WG2`^j7mCvIw)H2p~{`qh;H+wDY0a6o7Ce6yUp8-QRrw{MkvjtQwkmFjGow zTWV*<0Xo(OQfJNsC36~nM}iD7GiclS1SBlHz?_O4u%_9Cb~M3F(5Bo+Wguq<-QJsb zpZdLHsH4d)sN&TSSAG1Ysl;A}Vji@drG)uLv$kF*Vu^u^*v2kZMZVKJLKaK}5SImr zD7hGnwd(rvlL4p3*}k`bawH*ki)~~`3bDkOBhx7c-Gq$bp-8#pL)_ZE9ZGtgTo1hH-u~gAgx+-qt&Xfi zGN(1TAM+B+oZZ-}4ipGJxP`q`P?@eFQ%KZ%6!>%$+O6P(0rk;K+{_WPvxzpouos^j5dz|pry`D%@UkHA*>EGJWw%^!a&Q`>3!^(kO`#9RhPjrV0^86VO`syI|!jc6n6$HHC=2x%$E0-zc z33i!h|JfT=IVrNd6Uj;rAg$D_y1X74RqFLJ8JGvwq6_8ZaXHLM_k=KW#Jd`f2d_@3 zGbDMd)5kLyuuv;!wygUUULv+|Uy9G%sOT`cm7pCY+uUcRe;QR{O(9V;fysbW_$r_H zFKh;>D2@OM)0I&q{2#Fi-`gdp4E5)ZYdYGpYLgD@Kt`3SP;cfCXm6n@hWPDzTwkQc zzGb6h(2(Xp-;Qd?Vr=I46%m*;jt+ zl>sDa10!MmqXgkZuIfpW- zM((wAS5-F}0KtJsipxlvGkT19qIor)IV0)tP!tIa&_H)p*P46mORn?%=2=CEqw&s; z^6+psH@kNCd+plR)_@tv>=mts-zqxbf0LV=*{HL7$UaLBtW0zR$3hL<_Tdo;dWYUL z9IV11#N@Z^4syG&iG^1^J$^PFTOX?e!F~Jbp7nGlQ{@ipAOyeEDV6P-3DanZo&bz} zmno$qKUgu@VMbW9+uE+phw?7g|NNJ~etz8Nue_&d(qUyg&=I_eGsg+mv$~xw?1?IO zo^mLW^q%rY4XBpxqL8{0OyHQ}FGa`$Nn}+ZV?q$G9?*$gx&Qz`07*naR6-ml45{z` z_y(G4Ik^fJibX_J^OjFXCcxbR^16VYtRR*t;R@Pi(gmW)z1(by%8}g_gQf9QMuL(N zv!l_9NzqON6x~Tqrsm3R>HeJQX`jj`MPHKDyg#uRl9JB6~gWXJgTP zuo6=)93(?V0R?Q8@sum&-rNKmGP1`5cpFN@l|57@m&1zk#m!=mXX~oEO5l1(Nj5%+ zs=x=$q1jhwt8spNdU7>mTfW>qQgs_j#6k}^{f9Tke^V|UkhMS%UMM!6K;1}(q-~8^!mc4V|eY`!~KRiA-IXjiT!0^ax z0)!leb2tq@!+jqyp!}rs`8SrU%lXwkp-sc99xk{2s}FY?Q=Tn1ISFR`$pg9J^W)B| z8%#>{HgHQc3ag3`g{K-f5!DGKcU0$9gW}%mtIpc(-qrY%=pUgyU(W4(DV4_nONnSO z&fZ-Aa_CU$Q}yl)*g8>RG4Cmf7no&GxLL3Ekn(Z!RF#$#9sA>LSDL~p%7Bgv6`Up;NndcBEUl! z8L_NDX{G2rrD)4iRRIkzZnw5oz>z0+b^%4llhfs9i$1%9JIZaRYb0SO72N|pNFS@o zY_pLeuP6-**89n%Q_L!9BPE*}z!9B2xUQ~!V zxsaY!7=eP`cQX(6-GNtr8QwcN$iwk%;i{ytgL@axkOxomLK(b!pMTN5koMnDB z>@=lJ7&(}sxu(e)3+$`&M#NDRfQI`N1gIXwZjd}aBuy*X_l<@enA5XQcwf6!KRz0W zP)WR#lx5A6y;|9TUhROWddQVo9pIy7{F(%Dl%!sQ)#AoRwbTj|SONbXWjYHIyd1bJ%P5cj{8R!p4)9XRvt@yz(|FZV^I87O@`Sm4hhZ z6i*VwBS-*fK$pKCp=3fSgLVtzPJvM;C#Q5+)e|s=R!`Y=+k>4A$W~9~O91K2xd&G^vs~pv@#V~!m^5xFz1P=Q(WS*lCKn35tFof^6@>BKe z<2i)YLuBVYp(;(>-GOZ`7Cu{@RG(kEw{g3^b-N}gW9`7ListvN`bv2SZn{u$m|(>& zs<(O!w4;-RLFx)5)0cGH%Y0UY#_F7VBx8;cEH|Z7;bw(_Y6c!8&o;$h*&8~ZN79C$ zoH8!Bu?p`quqZp;4WY0$4V4$?!_J)Yy{4*QBrz>{EfQNK!|G4zKspjMEr&1jIMg*o z(FTggL(5T?a&Pb@IOUvd(c3I7s5*v7+(VAT`J4wfp1?nfpfyuro08y>L57_}t8_&c z?6z&b-k$yR>FTF*%Ntd=jrMNoYqQtIe(=S?PUk>T{O)$EVVAYmyN{>$_cA1T_UtcY zJA-P=popJ9)X+;Gy~5h0a8M37OFfr2JH4HW{nPK)XEt=)Ke)1~1%#rA8Df@0Ub7xI zTU)A|6fuNqjUkg&z@pm2{q=05aHz~P>)*fqYVgbNzRu3@wnC|~wT(PVbnYfEwpv`Q z)>bMq%^4~Tf?{!yxm2jdN`)SFFC)`b(7!z35daC`^d2Hr>VF6|?9z5Al8LN++TY(F zj%K}`J)ait8>3NI<1Le<$Jh{MLDYvn$XB38QTK3C9zQdycle`s$rQobJ&QTwl5ht} zaJ~r5+v5Yneg4dT1T7L!WH)QHF^sr{_;&GjVDYX*&!RMNAjLZJFHQ&tf>e;mEgM$+ z*0^H4ETFO91#GmiHXC^xB)>(NkR>01J#iDK)W(*|8<$ph%vOzV%kr&8t36vSxu1)K zkIZJG8HM3TvmiE$_z2`O^^dHUi`NY~bliJ_8Nm&{4!J1Uzfjw3eBTz+14;FlM>B5S zvc!kqiC;DI$ahCjwYnW{_NhKJ*6pNpc5?pv*Jr=l>RL&w3>eF%q?BZxA<#tn6l;M) z0c>h1N`dPk1Lum9}D zKmO*;#o5jNe&eT~-f?jX@mTR>|G|A<^xI$F9S)oB zFjeE>02J_ZA^bDE3GDcR@|IFzK)7p_t!>NUX8&r0EDcUYfQsR6^2Q-i9We1|DbB(` zFr9QmHF)P=jx<3~04_7I9GRPn5f`!wD775{q6xq0w$^f)HR;4m2GdM>_pPV z;o;F`br#XCYgW)A*x!HuM%oKPWxD6Ue$8PdEy$*BaeMmn+W9 zQbsK7v7?Dp&3X-H*R13u$7pRxBx5jwpr&!D7cwLI9`5Hj?USS30 z1DdLBdVVoN0GK9p34f~4Mi)a8X5X%D*Yf>5USWk9C#sDFhC&nflsj|37r3X>>0X|n zV?&37J*?;an6%5!49I;0d1zQzF(6CKstd!@XwB z(8TMS-OlfScq996F}`m1U5n2(gaT_f*o%2m8X+`NJc0-t9B!OmOm-xj^cziEFQ_2{ zLs$z*iEwvbrZeaREI{1Q^^UhlcAb&(Zf7ma%C7Q@`Rrq-V=+@U%-ln^Q+;v-yEJ@Uj6pBAAj~|M`fgG6(7L7gPdSn-a*)ezXn%>JdQ62P@Vg!n*9PL zA9|RX`d3G3LnA@Ia!xiSyXH@CtKO1dHBnfnAt8J-;z93qv(HEQT{1d+C9E^$+qb?<6=F)$HQe}o|l0-M61MH!FhS>|9_q)HL?Q0i!2)dQGR(}!oaUTE~c&pDnXm3$rWNeI-;#UT2 zbvx##R=0%6wB&O>y%#4TvAHd{jk5Xb7NlX!+e)~A!pbj&7qo8y#qHDMp1O!UYB6^y zb2)s*B}o8_o?twjuwiS#2~La(Qo#T^Jxf)a^1)CX%AjP*$kpZ0W{!dm1$zKqel!(R z-*J9AoC%?9dORc5Zdi{aEEms-4wC~wIi~_`AV1Ksx*2(h{xy% zP1&u+BIdYU$cQ9UbPCCNTvJh53RQ+%*q3)aS&~D&-M!9EPZ274{0hpFPJk4O4*9^M zFhTF*zbuzHCu5>H@K|#f4NLjeU~obM@yJ-C2>I^s1-^IM{r=IeQCmGB51_fR{p|4B z$McJ;voS@gJ?Mi@bvF=O?xqpWX*%6>GEx`u>sMdD{{9DU9EoRYuQeiRHp2xHZ+VlKJ%%RG8-k`H4FiWwa?;x*-gFlDc(zSM@VRX^yPu4yjpGNa6Ba>XO1SGmMHCcZfYWqZZzs5r`dfJnVY^5B9?VdXI&^!u7c+N5hz%b} z{*I!q$?$_H?n?LI!{Y&6u^@2LnZ)!^NHX$MOG)5DGZhaG^VYUk3hh`Bi8R7SQCrm* z`;k;|my`Sz1dC^r4}yHA(Qt!gnIM5gCA@vu5Z%90>G~lR zjg7mt?Pf2Pc5f`Sv{HRqx4liHb$HNj%&$+c<}xQ$@o2X97U~z?#b__dj=E`_YM?2J zw6+{i8aqz}bHYky%jWLhe0Dod;DuQxVe8IXA{sQI#i7AQ%cwIVnw9);-r|xRcXO+5 z_w{0kAwGY)XTOZa;>r@=xVjTek8W-`7}=ay271Hap+f>1S=|T8bAv_7?R6O+Q!B02 z4iym=Q`QYzGYAR>e*g`!o){TC zD;`OnUv^BSU~D$aM1oOq-cx^neDev$H2Y1CAczGRGM;$O5@-vG2giv<5}heh*81r} zq$2=COAozcvW1vPIGcm`!6b=eq%_uzZNq27u~ib^ym)CG@8A7&xtv?b@bu`pMS!G? zAeEh-!wK+yK9|L!*TL89u9(`dyl^IGG#gt~=42vs^X@_U?xbZ|DL}raj)*RX^25(U z{lxAab}DFSF`jni#(+nsyFJ+LqTY!T#7ard1;nWAT-a-i>{zZ4Pe<((bC<2kBton| zy!rjvU;XU>&696k>F9xr$U(>32A;nNIhb(u>Fyueko|`R>nu{uGH>H0hVrXNnyZPZshD zg#{K5R>4|%1m^5bVN%FOgGL)BJE$3D3u9fk%$ltYRa18f^B{!GVMrnbp`Db@xsx>3 z;t9qX@#T|GCziPH?Cx_a(pKpLf@3(D+!10F{AZJ)bg}WJHQe>Xy@RulXLf-VSmL7B zeyP7G?SkY}+H-5r>fbM}Kfe9x^3%m$f8&=wf3Y{%UQAVvk1ms74p?MNv_X8$J}cRF z5+}1;=Czu;*L*U31+Ck;isjei@oX&HXgIaw1j#~;f?$A9h&ql1jt4}UJVLaHQo?2H z4NFh9yOY`N+Ye_GnN-=Z&HNfxDiun~GZ&yt=qnLS>%cZjH8yere%vbPVfcUYzB}kxaPaBl z<<;eRw_|I9ri#a0G*&2cDZw9e0m(_QcC(`Zs{QNGizO;H2Z|_#mdzHsgsyK^#Wq7T zs0K@lGJ-N=YZ(L+3N4B-aXv+S)F0G)6fwaslytTT+@2zCQ+$|P28r1tZ?`*8I6(rl zWn?@V66ciVLyGE0ps^}qY~FK`bF0r)^&mMo?C@n|?cK6-+k$fjmVMKm>NWJ0Bo zChjK>KZ*`kEcHfFD=gOY;K9&(982Uw#ruk%0ytw*87?z{qaCWCB@s!O4TTU7eD$kd zdP?ui95gM68!j0>i;KqrOOMeFc|X)_q6~Pi0T#W< zCuP-524Zf48|l6=c&@P2=;$H4%s|HG_t0YI#Y_;wbc)cV+fsuZt4h@e8$c(+LD_eF zvR{|$e*N&_YAl#66^`k&UP-|L?(9k52fziES#{2glF*O{l-|g5?O3^|L z=HuIUo7-JpO*SOSm9?37Wn)AvSW)2lxUV!xM0;a0vlBQOw;2lnM6#5|umTz)0zwH9 z#dRG?iQ#55&2Z86hhAHarMZPq-@JPA^?5JpKa)jPlOLO??A+-!D$2b$!@ z=yB$m@JO0HW^M9D_5|Fj@AP*h$=M)gJfGl^^ksUpDPl_CwlSLS?moVKGrPJxKJ2~t z>PR7PX~eFo;%I(NA`Af+=8gWC>g2)34{_^7y{>I1oA|ZeXeL)GQmFHw=dpLgSE4Az z%z_BcncE0_yU;bQTud2#rF-YwX zfBdwunH|^?lrkmB<+SNn?fPb-2Ij&3zWfkx$Xoy`zDF~Mj&TE#7s}7i-@j8&f7I<+ z#+b_b1sMu$6pre-3J@$m%73v~(}Kpz>I@XY4(8M{nKXS^pqP5o!bp-$xRiPiS$x7? z?hYpD)#VLgu)8}D9+l$8r$OZc7Cuj;=3ZzN1tL@n8AG|1aa1TcU(&-6J@-*mC)caP zn*cY`-C-ijQBA*IaqwV9kdW^5_C=q+U!c_C<<;M|sc?x?YuFH>N`c_u|-Z)Udw<#KoG+Z&fx zS0|^Jd(U3phVo<7Qc&`MCt|fcPz9)%y28bmX^sfnQfv8qc=2?9@BPP52mRjjXD|Qo z{qJ^mb`%`B7gU$*&%r_ar>Ywr({-8k9ZAZug0{D#NIu2Q3>9R^00Sm$D*X=#3opn7 z|_CIRZSJ7u?(&5h~gaaW#DwdV>DlUc)pBPXc7EO*Va zb?#S)mQk_ZX{)1OW{hb7ErNjT6+lQY!t089fsfEkwvrFPFJt!xTc}mB1^&j&lzj$d+&Z4_)v`3p&PB8nG7X>nKfJUBky-#_Td>6L0GPg8pZ z0YN^HNvF&~q8SMpJ9AM}oNf|~>?*0a2?^?T0cilqv6z}=F=VO(Tmq#u6%EwAL(K2FV3`)0{u6)j*pHW*3M3> zSGu>%>u%c~IEg)OSPV$mAlnRq)^>f}@ATI8Zx{0qLRcx0ljTfMl+B74r!F|2#ix-8 z)M<1I*NR&&R8rsxT$nbn6x}X5g+ITzF0g1gh8qlsYH5q|239njoJ*%0jjtL!JA#b> z8F%bfOe05Ww&2Yp(FW@2uOXvkKbtt%k3&SY3`eS;onKP%FmpD1 z(hs6UhMZMU*TZlcFiAECM(bsiGp$9mn-KsMizl1gl{< z9YB{MML>JUK@|T%=ox3?{fGi+cCM`Z`{vcNSKmBS`);+GQXpclW@-u_jq;oR8nF-Q zVhCW3Z6WOeQ#5aaX&@Me7l4G0${Gh>3VN45L>qHd(2G`LKA1xO1rI#dv|3DoZw_U5 z0&{hwK>{{D6K8ZH@KZ<#t%HV z8_pC0o-ik>qm$qz-d$gx+JcRnb!Y*tu-UEAzJK@QuG$K{9`>h#!N-puC!>YJX=a0V zT-HUbQ%LnqrrCJ?9tcuYRO8Ddw{t`AEm&LK|HwMs>3W_j2RP=Q$Mdur9*!unmxy}a zNU?-?GTmZi9yB@~FKb=>Xugk_>WEEir(^PJ76HYyQ`v5Nx;LaK!l(CP1EkKv>K;_w#Dg?9vTZ9|`( zBN3f0Lskk;_VMFBD;})Se~ii$270bg;&NKZ;%Kv&?Lry^B35NpFoSs?l*d8@yEp zh-uDl?xi?g+fZz>#c(h|1=}K?K$JX*)|j0lG+90$8B3H6{zH2mI#jdWd)krltG6<@ z{?mW@C$(i5?pS~!^gA1VBA=2I0&}sqdx_HZBXx6$?z_8-lT%6-7H3>mZW<1-#9Ok~ z7>=$~Xj7PNK7W{u^+a*hnbMm0b+O`r%fR7BBuijd_0r^&12XuR*SVMDdWSXDWxO|DYrY;0Cf4W#$#myb?zFi~tX_ z@y$TPwK4tlB1Zhv~$*hS4tsBt=|R*$K&DEYXLGwXU)|F8D_<^Y>YV#12m}rEGg0jj`n;IToc{5^NaUDL2grs~0Ped1x z1{ISCE9;U|^(TwHu4`%{?eqkE1s%C`3X;*tpsfcpzkzmOl_N|VsRvp_LohmJ%iTd6 zi}zG~+#WW`1X1W(s+fhOGa;-dU4@(ijwh4cyzc`{j*YvPHC#f#45Z-GEMS>oaA3XO;j`No#kxPgyiiS--vz^)Tv53)7L%QrwAl;h*E+4*{*Fv%QK{>~`ehbV zSp+;24ht~N2XS36MEliYvZ7{%N?|-`n{1pMnwV>wo2jxU@JQ1`=D7nZAx~0s|6s7y zTD&`*zkmDo*_yC%j}k7`5SU}1mszV`QO(=qd3k?D-T+qh#SLHXi~#!+CRi~?SM<}{ zVFKbeVgi=_7nF-8l4o3u5?*nbkb5o~i58q_ziO&F`Eu?n=N=MA<3vQrD5jGTXVFS5 zF0{{vf@agPR5qENYa9KpEw!z@-l(?{y(crN1&fpi=48+u3dtvS1XP9gXe2zLA{CZz zLAXTo*S2nCHQ}$QBYk%X0EVGZ#3Y-MBoH48N9C?UWLNr?nLpp0sYBcW)+9hVXUD7Rk(D3X&{m7;TJMZ zfJQrT1@&hla-Ni>>PW!GyB_DrT7&hC7DoBQn@=Z~V>=bv+?T0X-(KT2j1bpBO~C>; zrLuuHf)=5XAs8K}-T8RDXZhCY>HT!E(`>$e^X~Xawd{K5dUL8ygLu>3%Bxc< zCTZo_)jaVn0^mLsp|+d#tI2eIy#uTK!4q6BT@J@xleu?quSt~T*BE;k7QN|OI>Df4 z8N2G$qiCWJ^`gqf!YI=VnaT-q5V)5na=sAlNm^j>gF>I9$n_9F| za%LXZwx!0|mi4wanGTneNo%VHW=F^B;?@^4ufC2#CnJzEv1GiMObDRgfj+UH8ePT0 zWzI=hD*OP4jouo!$91C>x3ahl3KaFd;k7@AYWRS2c$^}zrzXN)K+h& zO+`l(=-6aXm&`7SiTs<*hXb4PrAqzA<=Hul>BD;!_1rj)Cs5i27agR0SlizjC@16x z;ILS0Hi@Ry?9{ASZnsiO&8`(Y_Fhf`(#T@4@#wnMURNa&@}UuwK?azw!iy>%2scf9 zI8iyWX2|W%jmP!az*`EuNinilv1qy+&F(4M9-?Fc0WgQf zr0v3P$}2*&1wgLB@A6v|C-TX3e7@1;^ykZGekw=RMUX~HnKo|;UUU1!GII-75q4Ej zoNi^Nsd=;+VcFbWUS9M(A|&*iHi~-grwz1khc6tBQb=_D$-pN`@;nQEkq_a}Dn#R}>7W22eVk@z^GKG+)>>K`(_np#sbr zT_Ap)%aDMUI2S*YXJZ-6Vlotz3dJIV_w?$+EJgaAZ`{r;a52d zspRbitl+U^5*aDUfpo-h(F2Da8bVA(4&d;fHa*NI<&a3ou!gA9$lfHzlr9_?2qRNc z=YoJ16}7J-Mbh=TPv&{xGqZ`Rd|bM zV=daY3oLSyoeO9}i9cCVJRO+@OSc-^ZRo}AV1ep<{`#wL4rFO4l`Lsd?MV=Z*#L$x z=c{StRxBJLVS^=>7V=B@0gBRCbT()Q36f3RpxmwC z*_nE{VL>#b2>RuX?HyJgZrTc`6prH?Ny2Po(vY1U%sK1YFVNpdA7)dDaq2Ronu!9q{HBZR<%<_K%NSi;Kr@?WO1|c|Gtb#US#n`rh;g$qX-yk- z3Uc%5(_>&Kf`JHw25{-LGTV-h_I`SOA-|)hd=rx;N>#)G0O0c>mT;vgCr+vZ9GFVj zDue+9O_|p1jmA8QSOues#acnbqPt#y*J_RH`?~GKb^QElq8`}uS1+&y6j!);k_>o;>2_3Cs@Zq`FBgkWMdK)ZLbJvJk-f=3kL|eGug2me zcvxEVYv_XNszzmSEQep~|J&c}A}IkqYYZ&ClorUQ63%avq-pm~@elk(f}eWL;*3(n z?Ol9!MlN0nO+W_eDbUL~CLs|k=#dIrYR=tTu=V~^*6J%H!e@Imnx9>a?eH_1ublfF zO_o>V*>JRQ?o;`9T7rlKpAa^IbHFFVhd@4TEob6gq&(AXIG&1{8G`l~yC3NkXyKMf zJ}RReyd>gS-Q;P3w-A=u#0g@2(XZl;a50WeXk@Ac9N3M8g+#r|l5J5{mC2&95Tqd{ zJ}X@kBE+vypkyPag)E{cPhK|Kd+$CCfA@#ibI4QQCdEXtEj`$5v@jW*CuO#T%qL@R zIJ&2hpm_j94!Yv$OB6we5W2kz0DA&JV`c>OLqwJ>t?j61YUlbPp)Cj*Izqg@z*Jg9H9BV<7+q*&@|sR*|Uwl;+4blB;(=Hsi~oy})Y_Ur>F zsBZVX=(q*vfR@fu;1~22c>)Uhi%nZDu51gq0}a_KvR-sE8fwAD@_{DupBsQ2Wbq{s zP!2f6HLP*O%Kl1=)1c&Muan)%>i6?BH)Wq$?3kh zSiJn|2@%%b*gpUC@$}O>D#)|vgCm=m^LRWcnyz#J-mV{^xKK+*kW8H{mPomfM{0@P zl)T+6PpF6rg5cv=gbOKZo&9$O_7 zX8r8ryPnMt?yoyL{psk;Ds|PU%Z7nCG5{*OWB}1s!OACMHjC+A1`#n%6vHs`3{Ws3 zj;vHC*xH~jl1G7TJO%DgqOQ-%vEV^qpK|QWz$o()HwsTqsSg`RlI9Ws3w2ONsH;qB zC?~U?31yois2tltw&!HZr6BSpbA%E&zB-d_M0M@&wA3&JHsmP$B=rK&gKP=i(=m-C zSXyE!8IUfS^K_<6zG3=`P}9{f#~K7!zzjEL@<7I90GRuV2g(?VR^uU1mfX1}KLZod zc8EE7QGTcEDbp1{72tRYDV7F`A{HadvtG{*qdfBGFf(Y@;&oD0B8#yQtjvsH%(cV) zT~@8J>nP<0BOpvqFD^ZoRRh}%;tJl|<{W>C<`Gesmof$U#Z#57?4~omWWvt3w&vtS zYL-SjZdu>JFQ4%SCjl|lz$;aU) z;+MrKg|Gr4<0}Ui8NE-vqNIxChJ2Fd&5nAT(s;3U6acJr5#gfRho9aaKYtRg*4jQj zKaqG~F*@?WKcNRK(rnd^rgWpC?tt`vc2qs_%a;x0qh%BZ-4ZdJE3(vXyGl1yg*;_T01zUNeRxwsoUFD$9^q|XJ^iWIaE#3Ak}dI z&!>Pubq))K1?7jE8O>@J04NDLhaI1fbQ-cdGF04lbvLnA@kV*jzVdJVE)7qxUI-f> zqT@k0%m$k(kC=mXg?|<++0j#EnO3P8wYC1vp5Te;L9lo(qYfj@j|DD9!_YQ4t=1Kif_h9_L#_quzX4#-C?uWswZb67XCnCJZ&saLmj zTRc?@5FIDy7D%dS_)Uxtlw2u@cp)d3F}aRj0U}D2M(@=uv28H6;O3}bdPXF%Ky>e7 zIKRHzC^5hdx(4J6?Wo_JwD%h;QAwSkDG62ITuz20SLm{lgf@(l4k14o2S@FDyS>Y^ z;a~pwv-3~yO{AT3F(!d2$}THDN!#9A8P=(#TdcHDh{B6t?S}`vZ);2L=S^16(#nK* z1k*ND^D3;T%tyFv@HYRDkMs>dKoi+(;5JT4Btxi2my4^5n1N7w8&7vw?d_e$d)+Rl zlVsJqCZnMrMd$9W*Xhd!?Y6p?cNZLmhdaINrA3Jw{hh(iz-}ixY)LZ>0)7U*FbJsA zn@oL?8Mz8DA5KQdKgds})1fA!4mFUHP{m=gZ&xOvm`AIgL z^5zxKJk5Rs5pMP<(F`^4WcU$EkVX}d)1SD}d5N+?Uo?-sp+h8z>LZi(GcHr@n1@Kz~})kuykaVH~0rE_w0!%KgF%qmEsnh&Wx50 zN>89Hv8S5E7$GD%j$zj62;;;~Aq7mJ?uil6I0k)w0O|C1{r0w-728%#fUwHws1=R6 z+6B*!_AX9G-+aCE{>}U6PukoUhX*P(Spu7Z7SlDz<_L1c01T5g5155niZae2vMqRR z=h`{g%4k%SE6PJ0&GG^x=Zy~UEP-b;E@Xel6^$QjBG1cy2uIzNdJs&_%i^%bQjA5^ zyR|KD8S4QzPh4Aw3vZ73nMhQl-LaDoKddIN&WD!bAMJIz_UY=id3ag$G0$~1XjK}g zp6E*soeE`YIi36yHfmB_fxJFVLG_c*JnGKyu1s8MA<)QAoRcY5TPDhkC3_^y!U0h- zdXKTM$a#2A$+Q{}y_ENQJZ5-B?%WjFY%y9HJ0X^!oAVR56Kqcc26+ZB7HAQvK()7L zQ^k8LU)&-c+ltsux2c|swXnb+*Fm=}-I%V2^T;_>^S88zXe_6OhXDk)_YD;U8}|xw zPUN)kEn4(sx(cdhYIFpG<>l&Rb7<*fgjzUMfpLLOPzjW( zE865zumm)-5TqgZrOM=f&h88zprT~d`NRbBsMivE&HwFx{l9+uSAX~O;rYdr{lkyv zr*`V&s>yVDWdNQdb7U~#h&f#O(-R#o_dH(ARGCrIAxe>t@M-XeBgA~%Lxo$!Bq>c{ ztGV(g$m45iBQNplY7|b~v!N_gaLN${Fc`$DBt87aipgu!9RSFv)xR_Sp)srsr-=e< zQ)?r?8JGM%EAeSo<`G(jRMd7-Iv@)q|LUiYtYwNx3Z|q29Q8xE{&~ zwQ=Y4hH{0~VoWf_=u2pVI>Zr?eTTfldig|5=q;iJ($Ma1-oO6|-{s@WY=xHMyOHsZ zG38w_tB53H^VPUD=bw!TCs`tRG|w=F?qO%It@Erib+932Ce0I4Oy)eL(@V>U7V4P- zQJ@nmL&~q1L{O@_GfkIA z%o&9-aucnhrzpb316XtgcU5nf3yb3yx_B5~PQAS<@esn%+rr?o>N1_B8%JHD{l}z= zDY?zPrcMnpD=gBq(0_fCqa112u3Wsxo-SM>_|sI-W3jNZE4{-9m{wWxE1f+4A4VGV zvLA$3@FNvolHp)5nN6K5<{hvACh{H*Ex)hv$$3@|-(774v-fugln)zV7U&i07URgm zc*+E91fU420;K}d5L2X)w}fAZLxkb-gvdTMcs^iVeaMPa95cq}H!r^a;r09No!Wo@ z```chUw*^U%xJMMd85^ERU6DK$0NIrXJEL0#u;+R7>(FuXj04AL2(ge zHYI|}lovsZx3f4gmKl1gHw74a2XJI`xLp{6nnd1DNvtX03Q0jLVGllQy3jX74K`vO`fC6o=>%lK%1z0M z)c(E2zLB4x6o|oWvm*j!%t$^ltu}6+Dz5Vx_hr~!aFLC?Ox-u=X0J7-z|rxDk`>vlXn3N4oW+NLD15b zWUd5VLN#vIMzulCLa8OL0r5|cyq&g09kFF$c?A?5AK!iw{S>H?c{ZI`wy?TWkzWoZ zhmuW)Edi4R8HiYig3?K(sM_hjv6Qqt+eJn#$RHzU6C>gg85ABl%VRV&uBH_g3VmpLvi|riZ4XB|~IVvmw1?>ZV#+Aam>sCk`G{gak@E(2= z3`jRs$@%%WKYRV=O}F##kAMHeU;ORQs4&8MmEK#xGrI+PxC?)|+qg?uV%kE7DH*7+W-uZ09bmW! zvs=3cOiU;Z0+#^=-vZGu-6{l#8c_lpT#Hh3;@xJ`+)E>f1XzY?Lq43y90e(L`K~kQ zGM^BeTQaBwfn&%IhaB^NE9ee~I$D!q= zGY6KF+2-v-yNBuoKt<%&O=w1`r4R{t-cQD(ogUwA<3Ie(uf)DAAhZBJg{)GC2yh}! zusSGYL`(W?nJq(rKp+5z;1zX4gBbV6JZ+B#AP;adn606MqAV6MYP$isDVPzRtI<2>GFTDxX#n49?2*KCk#jxDq zdT=(VQ$`l7@O&Zf>Pl&?M!Ub+cw7bJN(NW%Q^+~(^KPL=vbE^N+LF`lix(BqpQvv- z%D9x1TJDSq3l-q4XEH~dDrsf8_MLJw^V7@APiMwj`_6Xa&89kA_M6!0b&*Gqld$H%Izzb07?qJ7S@o3 zNY$&#`vS;6J@M5IUyl4#ey?!0%pF>Bm)1h~1)ZXjq2~PRe(wU4V2f}5DgZ?-aHYDa zefJVI>dQ6y{Q-|!@usBcD-fd=hmwSJ1mX_PC(^%OKRG|6-R|zS`?4EaSyL>V4@4x= zCSxe_N&{gl`bR4j2uT;h%d$Yn+XLvh*dw4aw9lV0qhmL;7LrrAGn1!S)+G@EcrNA{ zG4~L6z3C~cjWjw%D)Dp~kew~-BcjhH?_qVVu#kU~Z*pV9Q|!~bf*if}z)J%^R#NyN zAc=lrW-8N1(F~tsSZg$4==JGh!q1Sbo%jihzXolJ=gK1~ig2N1?Lqh=@hm@A-vwyn zBnT}iXqUMpeF~nM0tzbkYOdARo9aj+u-Y}w7pO&?MHa23)S$gE-Hf^-!!W3uDXeR6 zZ)~-@tNESP4)uHc%i{N~o6DtS7W$oK3Bm&G_Pza{WMTGmYokT&z%0rpx1^I~wcGvv zmhj2Jey7)O-Yjp=PtKpecmj{u1E6Mf;-D01po2GoUBMza0Q__@Yuv$+8p;VMv|Rx5~GVwT6V3KKG<7;CW>V6bx`a>bP;IO4!Ha|n{ zv)QFM{Z40t)v(%}OYvjp{P6#r{Q3WKaJ0MebGzw28C+bvm1N;v1qC8>u>p;HBe!+Y zJ(EI&v$;Ylxl^JT5;mjhQemm4(&L$IU+uP=)t?Sx+}D~qt7ZH+MqwUqF7Uw^4XzMd zX(u@5KX5R=K@287QL5!M&SV2YkYx0Py%L*o6Tw~^y}&Qnoe*JOkzNBbD8;d?wjl$& zARFt)lthWxZdxDsQQpZ!;n&(=Z}Wfs^{?qcVp{?c7sJJ~r%WG!i+sR5< z-#Ja^a~Kpk1Y^~V6wry<5qMO57W|!u&&oXroS<05f8Io1fE`BQ{E-s}@1s(gSmk3R zk}Rf+moJ`wI6b)--~8~y53hdqt&C>L_R-|v6-_6GBcNef2?&9HFwwt-+c?oWGV`xK zILbXBksJ|FsqD;xqmU@7iPk*f1l7Wz(p1Gst8)#9qSH=!3w-m{goR!GpZ|yDYIZx3 zS|2V~F#;1QIb2>WBXoOHa??3A**xU3-U@zn#IbMoBwbL&4@O4Z2a9cdBT} zr%&VK;~kE|jrG1o9TEuuRzRu08KHMKwg|QcdO}IY&LZfN?V<1K8Cg16)$n@@5vgrh z)nYO}*x$K2J8`EmDjUMqXSjQW1`RVPXHs=x($=QLKbd;+^cjYxpX1ybbJ#5G1dtnq zzP=?ScU_mzE{}M1XN!@BZHq7lF)hV4(yVz#J^&F&=p4^iL-a%h9nne4w_psuRAL-c z-QCx2=!v1JgWIskYu$~nvM#ip=#EZ z=7IeOhwYCaXTSJ)|Chgb)oO1}Mi=whu-)0V&zLPvgWN$VDCZL>qI4p4=atD!7Gb+! zSO@~wMTZ+d{O}_)f-z0Kx9_wX7e-~^1Rx9*#x%~7iw-D?BF2BpiJZi&>)Y{Yxxc4` z@#OIE$-DO-`LCyTyg5G=-C5u4^xK0$ubtg<@n`!*a_TIn@87=DO}_H|gZj~vL%GAc znM~4K0iUv*IzwPVy%$7ub2vu zrmvV^AYXA_v!80~;ro>N;&w~;Dm={6?JK5NV&EIKq?2|#JXeuhr2E>sRq^MaE^H(8 z{P;+Qh8@NBcY899;hV2m1_KeQSh6N9Gq^;ovdrT?44DaiuU&JxsYPwd+NT3s$3AUYx$^SG~r~0IG@+x5a;0J z)8Tc|6>D&w^l;H+`pcRdTQE{*Z+8bRb(HUJI~L%`pJ7iv+dpn}V##|P+tRb7IjF)r zoDr?rlVEe}W+Ta%wzE$ed5f~G8X@g?EUzD|+T82%A2ojQ^KUXAW;kz5Z*T!h^FRFl z-E3mkH*K;yXl<=uPY(Om`KbECB-!kAQ*GX1cJUx9aV({I_wnlF{kdvTGL{V3a*^fu z?r^`!z70-uqLj*elr?4KnlYLz^tFN2-S*we9@_oQnuP*;F9s$Pb5GjwQsED=1d;Gn zySpuluqoBl24eT3x9eHb%Nx2y^pHYAM&!gL3aRP9cPzuR&#w5D3U!gk`j^NBkz#^r z0&ytr|EQAb9W z!%fu*e&mM2c#&r&6f(nq(nyvVqJrm`TS$>jQj=O2NtUFdV%TQM{sU~>RB&2e4VwMwGO*dUrFU}nCB}NOrf>ln#Oj0}K0R2BCO>^R5dwYs z>}NQaS@2OQvynr73XW9#6LfJuj|U7I@lVh?EmgoNEz+0pcq>#gd0}4Ga)qbj6exy)l~?j1O0QVYimXN>wO-ntQK#FtzxdJK&h<)+g`}jI zUbnVuU%wDs8MK>pv5YX$$8y|kur$~I?Z5f%hc~CPOiw-UR>JA~-+nkf zozhq}xWC`)^qT!1&Ydj!I7pNOum$1r+Wu72k@YN`0`oO+P}Uj~ZPq*OhJ-SfmgoU| zar1a)la;K9r4(l-vZ^Wz&pB5hW}3vaZ*C6y9Rfc(sFgxUh=M~+eYo9vSg2saZw%yA z0ZDY=BDsb?JPXw92OhsR&r~+Hv6D}a38<2Tg;JcwrQ9bQ&09==za1rXCi$7?O5NFs zitg$+@9g!N#vHOYd%GQb4Gb^I&e}IGUv#=zM?^95$tb=TSIy>b$vwCOH?qC*$t7fHX#s*lyzM6f7cl!eaw_MGV&Ov`yU8{fk zr+-TTp!>xb*Z@>BaY4hrI@zqi7=`-W% z9`1Md_I8*8v*mc905vd5MN%;A^Pz{1s>4`bhET|$x>${fx)-St>d0A`Y|3@t&|xt~ z6Jmr2wYoVfoAHFOL=G4wVG+LJb62B9257zYc4EcRR??JNh8jWi!cM$^_|$GvzUV08 z{@F~Ozgb{1o(G0Y77=i=G4^1STLa?CbW7*G&M@7k&U%v-$@rjJSRKorBMrQ&Vgd?< z047fmzB^n$dwuzSemc8-_-Ko7Y7p|3q@gOB)j=EIwS1pf;l`M7ixuf=uClxGOZugLvgOv}S8@ydWJO0iCd zgXh>O%&Dhd89H&w90T}cu*1WKl`A(BE+tY$W#zsFj(JDa=vNAhd1c>|25R+dp^YV^Ly zTzJFA1w4AqS6@A2JMY>3mWZ_L#eDivTGh&0Bdd7rN+tPRa7bMgDVgzeWS2}UR<}B< z*^LTN$|YIIve(;q@$}&5-@UlKo!-$e@jnZHIjg{$wpQPmjL+NM&dcXJ4S~neY&1{% z-@Q7VPS07TafkAxi?-vSONwdzSKocLx%Q5Y^7YGSJ$t$GLoonv7s(l{-&!3PCA8D! zX>U_aR0j(--ts&Usz)4&fA|p(p`s_SCiRP zm7eh=wVnR%-~Abmmf%prZdRSBX=AEsFA$g^n4_TPW9O^$FGyL9C?tZrVE^}GeL1(; z2|*S*rPA5XkC&Qijq)wZkgFE^@wVBpL}x{ZwLpj|{@dUD{_=t|xAyAg6K$?4? z)F%XuoxKAUik9=?S5I~(R~N6o-F^4t<&(p>jDGgbi~T{9u{4{HEnua&&_RM9KvNP#ypp-7xU79mJPqauw73dYAtm&LH89x}&0 zLt4q*VPHv#nIyyMn%Wh~N@wz?eN9%!lC`DztfI}Mu#_=@`v`cr7&2#J%0D$KTw+}` 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�WfTb7_`meQx;36ttn%^EM_xPgj4 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≤y|HRj!k| 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 G?0*1RvDBmh diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 27dd38a..871195f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,62 +1,100 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Toaster } from 'sonner' +import { Loader2 } from 'lucide-react' import { Sidebar } from '@/components/layout/Sidebar' import { MainContent } from '@/components/layout/MainContent' -import { AuthorFooter } from '@/components/layout/AuthorFooter' import { CrawlerConfigPanel } from '@/components/config/CrawlerConfigPanel' +import { MonitorDashboard } from '@/components/monitor/MonitorDashboard' +import { ReportView } from '@/components/monitor/ReportView' +import { SettingsView } from '@/components/settings/SettingsView' +import { Login } from '@/components/auth/Login' import { EnvironmentCheck, isEnvChecked } from '@/components/env/EnvironmentCheck' -import { LicenseDisclaimer, isLicenseAccepted } from '@/components/license/LicenseDisclaimer' +import { authApi, setUnauthorizedHandler } from '@/lib/api' + +export type AppView = 'crawler' | 'monitor' | 'report' | 'settings' function App() { - // Initialize by checking localStorage if license has been accepted - const [licenseAccepted, setLicenseAccepted] = useState(() => isLicenseAccepted()) + // null = still probing. Rendering the app while unknown would briefly mount + // the log WebSocket before we know whether the user is authenticated. + const [authed, setAuthed] = useState(null) // Initialize by checking localStorage if env check has passed const [envChecked, setEnvChecked] = useState(() => isEnvChecked()) - // State for showing disclaimer manually - const [showDisclaimer, setShowDisclaimer] = useState(false) + // Which top-level workspace is visible. Only one is mounted at a time: the + // crawler view owns a live log WebSocket and a 2s status poll, which should + // not keep running while the user is looking at the monitor dashboard. + const [view, setView] = useState('crawler') + + useEffect(() => { + let cancelled = false + authApi + .me() + .then(() => !cancelled && setAuthed(true)) + .catch(() => !cancelled && setAuthed(false)) + return () => { + cancelled = true + } + }, []) + + // A 401 from any request means the session expired or was revoked elsewhere + // (e.g. the password was changed on another device), so drop back to login. + useEffect(() => { + setUnauthorizedHandler(() => setAuthed(false)) + return () => setUnauthorizedHandler(null) + }, []) const handleEnvCheckComplete = () => { setEnvChecked(true) } - const handleLicenseAccept = () => { - setLicenseAccepted(true) - setShowDisclaimer(false) + const handleLogout = async () => { + try { + await authApi.logout() + } finally { + // Even if the call fails, the local session is over; unmounting the tree + // closes the log WebSocket via the existing connection-count cleanup. + setAuthed(false) + } } - const handleShowDisclaimer = () => { - setShowDisclaimer(true) + if (authed === null) { + return ( +

+ +
+ ) + } + + if (!authed) { + return setAuthed(true)} /> } return (
- {/* License Disclaimer Modal - Shows first or when triggered */} - {(!licenseAccepted || showDisclaimer) && ( - - )} - - {/* Environment Check Modal - Shows after license accepted */} - {licenseAccepted && !showDisclaimer && !envChecked && ( - - )} + {/* Environment Check Modal - shown until the check passes or is skipped. + A configuration concern, so it lives inside the authenticated tree. */} + {!envChecked && } {/* Header Bar */} - + {/* Main Area */}
- {/* Config Panel - Primary Action Area (Always Expanded) */} -
- -
+ {view === 'crawler' && ( + <> + {/* Config Panel - Primary Action Area (Always Expanded) */} +
+ +
- {/* Console - Collapsible Terminal */} - + {/* Console - Collapsible Terminal */} + + + )} + {view === 'monitor' && } + {view === 'report' && } + {view === 'settings' && }
- {/* Author Footer */} - - {/* Toast notifications - Theme-aware style */} void +} + +/** + * Full-screen login gate. + * + * Never an overlay on top of the app: the authenticated tree owns the log + * WebSocket, and mounting it behind a modal would open the socket before the + * user is authenticated. App.tsx therefore renders this *instead of* the app. + */ +export function Login({ onSuccess }: LoginProps) { + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + if (!password || busy) return + + setBusy(true) + setError('') + try { + await authApi.login(password) + setPassword('') + onSuccess() + } catch (err: unknown) { + const response = (err as { response?: { status?: number; data?: { detail?: string } } }) + ?.response + if (response?.status === 429) { + // Throttled, not wrong -- say so rather than implying a bad password. + setError(response.data?.detail ?? '尝试过于频繁,请稍后再试') + } else { + setError(response?.data?.detail ?? '登录失败') + } + } finally { + setBusy(false) + } + } + + return ( +
+
+ {/* Corner accents, matching the other full-screen gates */} +
+
+
+
+ +
+ + + 综合采集平台 + +
+

+ 需要登录才能访问控制面板 +

+ +
+ + setPassword(event.target.value)} + placeholder="请输入密码" + className="h-10 font-mono" + /> +
+ + {error && ( +
+ + {error} +
+ )} + + + +

+ 首次启动的密码打印在服务端启动日志里。 + 忘记密码时,设置环境变量 MC_PASSWORD 后重启即可恢复, + 并在登录后于设置页修改。 +

+ +
+ ) +} diff --git a/webui/src/components/config/CrawlerConfigPanel.tsx b/webui/src/components/config/CrawlerConfigPanel.tsx index b09762a..5b9c8c3 100644 --- a/webui/src/components/config/CrawlerConfigPanel.tsx +++ b/webui/src/components/config/CrawlerConfigPanel.tsx @@ -1,5 +1,5 @@ import type { ComponentType, ReactNode, KeyboardEvent } from 'react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Database, Globe, Image as ImageIcon, KeyRound, MessageSquare, Play, Square, X } from 'lucide-react' import { Label } from '@/components/ui/label' @@ -8,7 +8,9 @@ import { Input } from '@/components/ui/input' import { Checkbox } from '@/components/ui/checkbox' import { Button } from '@/components/ui/button' import { useCrawlerStore } from '@/store/crawlerStore' -import { usePlatforms, useConfigOptions, useStartCrawler, useStopCrawler } from '@/hooks/useCrawler' +import { useConfigOptions, useStartCrawler, useStopCrawler } from '@/hooks/useCrawler' +import { useCurrentPlatform } from '@/hooks/usePlatform' +import { useCookieStatus } from '@/hooks/useMonitor' import { ParsedIdList } from './ParsedIdList' type SectionProps = { @@ -137,9 +139,19 @@ export function CrawlerConfigPanel() { const updateConfig = useCrawlerStore((state) => state.updateConfig) const status = useCrawlerStore((state) => state.status) - const { data: platforms } = usePlatforms() const { data: options } = useConfigOptions() const { mutate: startCrawler, isPending: isStarting } = useStartCrawler() + + // Follow the global platform selection rather than keeping its own copy. + const { platform: hostPlatform, capability: hostCapability } = useCurrentPlatform() + const { data: cookieStatus } = useCookieStatus() + const storedCookieOk = cookieStatus?.present ?? false + + useEffect(() => { + if (config.platform !== hostPlatform) { + updateConfig({ platform: hostPlatform }) + } + }, [hostPlatform, config.platform, updateConfig]) const { mutate: stopCrawler, isPending: isStopping } = useStopCrawler() const isDisabled = status === 'running' || status === 'stopping' @@ -165,22 +177,15 @@ export function CrawlerConfigPanel() { icon={Globe} > - + {/* Driven by the global switcher in the header. A second platform + control here would be a second source of truth for the same + decision, and the two would drift. */} +
+ + {hostCapability?.label ?? hostPlatform} + + 由右上角切换 +
@@ -300,14 +305,23 @@ export function CrawlerConfigPanel() { {config.login_type === 'cookie' ? ( - -