Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ query_engine:
wikipedia:
enabled: true # Wikipedia 热门话题
timeout: 15 # 超时(秒)
wikipedia_top_views:
enabled: true # Wikipedia 热门浏览(真实热搜数据)
timeout: 30 # 超时(秒)
lang: "en" # 语言
ttl: 21600 # 缓存 TTL(秒,默认 6 小时)
bing_suggestions:
enabled: true # Bing 建议API
bing_api:
Expand Down
14 changes: 2 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ version = "1.0.0"
requires-python = ">=3.10"
dependencies = [
"playwright>=1.49.0",
"playwright-stealth>=1.0.6",
"playwright-stealth>=1.0.6,<2.0",
"pyyaml>=6.0.1",
"aiohttp>=3.11.0",
"beautifulsoup4>=4.12.3",
Expand All @@ -35,17 +35,7 @@ dev = [
"pytest-xdist>=3.5.0",
"hypothesis>=6.125.0",
"faker>=35.0.0",
]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-playwright>=0.5.0",
"pytest-benchmark>=5.0.0",
"pytest-cov>=6.0.0",
"pytest-timeout>=2.3.0",
"pytest-xdist>=3.5.0",
"hypothesis>=6.125.0",
"faker>=35.0.0",
"respx>=0.21.0",
]
viz = [
"streamlit>=1.41.0",
Expand Down
50 changes: 36 additions & 14 deletions src/account/points_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@

from playwright.async_api import Page

from constants import REWARDS_URLS
from api.dashboard_client import DashboardClient

logger = logging.getLogger(__name__)


class PointsDetector:
"""积分检测器类"""

DASHBOARD_URL = REWARDS_URLS["dashboard"]
DASHBOARD_URL = "https://rewards.bing.com/"

POINTS_SELECTORS = [
"p.text-title1.font-semibold",
Expand Down Expand Up @@ -90,11 +90,25 @@ async def get_current_points(self, page: Page, skip_navigation: bool = False) ->
logger.debug("跳过导航,使用当前页面")
await page.wait_for_timeout(1000)

# 优先使用 Dashboard API
try:
logger.debug("尝试使用 Dashboard API 获取积分...")
client = DashboardClient(page)
api_points: int | None = await client.get_current_points()
if api_points is not None and api_points >= 0:
logger.debug(f"✓ 从 API 获取积分: {api_points:,}")
return int(api_points)
except Exception as e:
logger.warning(
f"API 获取积分失败({type(e).__name__}: {e}),使用 HTML 解析作为备用"
)

# 备用:HTML 解析
logger.debug("尝试从页面源码提取积分...")
points = await self._extract_points_from_source(page)

if points is not None:
logger.info(f"✓ 从源码提取积分: {points:,}")
logger.debug(f"✓ 从源码提取积分: {points:,}")
return points

logger.debug("源码提取失败,尝试选择器...")
Expand All @@ -107,13 +121,14 @@ async def get_current_points(self, page: Page, skip_navigation: bool = False) ->
points_text = await element.text_content()
logger.debug(f"找到积分文本: {points_text}")

points = self._parse_points(points_text)
if points_text:
points = self._parse_points(points_text)

if points is not None and points >= 100:
logger.info(f"✓ 当前积分: {points:,}")
return points
elif points is not None:
logger.debug(f"积分值太小,可能是误识别: {points}")
if points is not None and points >= 100:
logger.debug(f"✓ 当前积分: {points:,}")
return points
elif points is not None:
logger.debug(f"积分值太小,可能是误识别: {points}")

except Exception as e:
logger.debug(f"选择器 {selector} 失败: {e}")
Expand Down Expand Up @@ -143,7 +158,7 @@ def _parse_points(self, text: str) -> int | None:
Returns:
积分数量,失败返回 None
"""
if not text:
if not text or not text.strip():
return None

try:
Expand Down Expand Up @@ -310,7 +325,12 @@ async def _check_task_status(self, page: Page, selectors: list, task_name: str)
Returns:
任务状态字典
"""
status = {"found": False, "completed": False, "progress": None, "max_progress": None}
status: dict[str, bool | int | None] = {
"found": False,
"completed": False,
"progress": None,
"max_progress": None,
}

try:
for selector in selectors:
Expand Down Expand Up @@ -338,10 +358,12 @@ async def _check_task_status(self, page: Page, selectors: list, task_name: str)
# 查找类似 "15/30" 的进度
progress_match = re.search(r"(\d+)\s*/\s*(\d+)", text)
if progress_match:
status["progress"] = int(progress_match.group(1))
status["max_progress"] = int(progress_match.group(2))
progress_val = int(progress_match.group(1))
max_progress_val = int(progress_match.group(2))
status["progress"] = progress_val
status["max_progress"] = max_progress_val

if status["progress"] >= status["max_progress"]:
if progress_val >= max_progress_val:
status["completed"] = True

logger.debug(f"{task_name} 状态: {status}")
Expand Down
5 changes: 5 additions & 0 deletions src/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""API clients module"""

from .dashboard_client import DashboardClient

__all__ = ["DashboardClient"]
170 changes: 170 additions & 0 deletions src/api/dashboard_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
Dashboard API Client

Fetches points data from Microsoft Rewards Dashboard API.
"""

import logging
import re
from typing import Any

from playwright.async_api import Page

from constants import API_ENDPOINTS, REWARDS_URLS

logger = logging.getLogger(__name__)


class DashboardClient:
"""Client for fetching data from Microsoft Rewards Dashboard API"""

def __init__(self, page: Page):
"""
Initialize Dashboard client

Args:
page: Playwright Page object
"""
self.page = page
self._cached_points: int | None = None
base = REWARDS_URLS.get("dashboard", "https://rewards.bing.com")
self._base_url = base.rstrip("/")

async def get_current_points(self) -> int | None:
"""
Get current points from Dashboard API

Attempts to fetch points via API call first, falls back to
parsing page content if API fails.

Returns:
Points balance or None if unable to determine
"""
try:
points = await self._fetch_points_via_api()
if points is not None and points >= 0:
self._cached_points = points
return points
except TimeoutError as e:
logger.warning(f"API request timeout: {e}")
except ConnectionError as e:
logger.warning(f"API connection error: {e}")
except Exception as e:
logger.warning(f"API call failed: {e}")

try:
points = await self._fetch_points_via_page_content()
if points is not None and points >= 0:
self._cached_points = points
return points
except Exception as e:
logger.debug(f"Page content parsing failed: {e}")

return self._cached_points

async def _fetch_points_via_api(self) -> int | None:
"""
Fetch points via internal API endpoint

Returns:
Points balance or None
"""
try:
api_url = f"{self._base_url}{API_ENDPOINTS['dashboard_balance']}"
response = await self.page.evaluate(
f"""
async () => {{
try {{
const resp = await fetch('{api_url}', {{
method: 'GET',
credentials: 'include'
}});
if (!resp.ok) return null;
return await resp.json();
}} catch {{
return null;
}}
}}
"""
)

if response and isinstance(response, dict):
available = response.get("availablePoints")
balance = response.get("pointsBalance")
points = available if available is not None else balance
if points is not None:
try:
return int(points)
except (ValueError, TypeError):
pass

except Exception as e:
logger.debug(f"API fetch error: {e}")

return None

async def _fetch_points_via_page_content(self) -> int | None:
"""
Extract points from page content as fallback

Returns:
Points balance or None
"""
try:
content = await self.page.content()

patterns = [
r'"availablePoints"\s*:\s*(\d+)',
r'"pointsBalance"\s*:\s*(\d+)',
r'"totalPoints"\s*:\s*(\d+)',
]

for pattern in patterns:
match = re.search(pattern, content)
if match:
points = int(match.group(1))
if 0 <= points <= 1000000:
return points

except Exception as e:
logger.debug(f"Page content extraction error: {e}")

return None

async def get_dashboard_data(self) -> dict[str, Any] | None:
"""
Fetch full dashboard data

Returns:
Dashboard data dict or None
"""
try:
api_url = f"{self._base_url}{API_ENDPOINTS['dashboard_data']}"
response = await self.page.evaluate(
f"""
async () => {{
try {{
const resp = await fetch('{api_url}', {{
method: 'GET',
credentials: 'include'
}});
if (!resp.ok) return null;
return await resp.json();
}} catch {{
return null;
}}
}}
"""
)

if response is not None and isinstance(response, dict):
return dict(response)

except TimeoutError as e:
logger.warning(f"Dashboard API timeout: {e}")
except ConnectionError as e:
logger.warning(f"Dashboard API connection error: {e}")
except Exception as e:
logger.warning(f"Dashboard API error: {e}")

return None
2 changes: 2 additions & 0 deletions src/constants/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@

API_ENDPOINTS = {
"dashboard": "https://rewards.bing.com/api/getuserinfo",
"dashboard_balance": "/api/getuserbalance",
"dashboard_data": "/api/dashboard",
"report_activity": "https://rewards.bing.com/api/reportactivity",
"quiz": "https://www.bing.com/bingqa/ReportActivity",
"app_dashboard": "https://prod.rewardsplatform.microsoft.com/dapi/me",
Expand Down
4 changes: 3 additions & 1 deletion src/review/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ class ReviewMetadata(BaseModel):
pr_number: int
owner: str
repo: str
branch: str = Field(default="", description="拉取评论时的分支名称")
head_sha: str = Field(default="", description="拉取评论时的 HEAD commit SHA(前7位)")
last_updated: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
version: str = "2.2"
version: str = "2.3"
etag_comments: str | None = Field(None, description="GitHub ETag,用于条件请求")
etag_reviews: str | None = Field(None, description="Reviews ETag")

Expand Down
Loading