first init
This commit is contained in:
13
core/routers/__init__.py
Normal file
13
core/routers/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午3:48
|
||||
# @File : __init__.py.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from .auth.routes import auth_router
|
||||
from .card.routes import card_router
|
||||
from .im.routes import im_router
|
||||
from .file.routes import file_router
|
||||
from .user.routes import user_router
|
||||
|
||||
routers = [auth_router, card_router, im_router, file_router, user_router]
|
||||
6
core/routers/auth/__init__.py
Normal file
6
core/routers/auth/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午3:48
|
||||
# @File : __init__.py.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
72
core/routers/auth/routes.py
Normal file
72
core/routers/auth/routes.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午5:02
|
||||
# @File : routes.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from . import schema
|
||||
from config import settings
|
||||
from core.db.engine import get_async_session, get_cache
|
||||
from core.db.models import User
|
||||
from core.db.crud import CRUD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_router = APIRouter(prefix='/auth', tags=['认证模块'])
|
||||
|
||||
|
||||
async def get_wechat_session(code: str):
|
||||
url = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
params = {
|
||||
"appid": settings.APPID,
|
||||
"secret": settings.APPSECRET,
|
||||
"js_code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params=params)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Failed to fetch session from WeChat")
|
||||
data = response.json()
|
||||
if "errcode" in data:
|
||||
logger.warning(f"WeChat session error: {data['errcode']}")
|
||||
raise HTTPException(status_code=400, detail=data["errmsg"])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@auth_router.post('/wechat_login')
|
||||
async def wechat_login(
|
||||
payload: schema.WechatLoginIn,
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
redis_conn: aioredis.Redis = Depends(get_cache)
|
||||
):
|
||||
# 获取微信 session
|
||||
wechat_session = await get_wechat_session(payload.code)
|
||||
logger.info(f"WeChat session: {wechat_session}")
|
||||
openid = wechat_session.get("openid")
|
||||
|
||||
if openid:
|
||||
raise HTTPException(status_code=400, detail="Invalid WeChat session")
|
||||
|
||||
db_crud = CRUD(db_session, User)
|
||||
user = await db_crud.get(filters={'openid': openid})
|
||||
if user is None:
|
||||
# 新用户注册
|
||||
if not payload.encrypted_data or not payload.iv:
|
||||
raise HTTPException(400, detail="需要用户授权信息")
|
||||
# user = await db_crud.create(
|
||||
# data={
|
||||
# 'wechat_openid': openid,
|
||||
# 'wechat_union_id': union_id,
|
||||
# },
|
||||
# filters={'union_id': union_id}
|
||||
# )
|
||||
|
||||
return {'data': {'openid': openid}}
|
||||
16
core/routers/auth/schema.py
Normal file
16
core/routers/auth/schema.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/17 下午2:30
|
||||
# @File : schema.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, constr
|
||||
|
||||
|
||||
class WechatLoginIn(BaseModel):
|
||||
code: str
|
||||
phone: Optional[constr(pattern='^1[0-9]{10}$')] = None
|
||||
encrypted_data: Optional[str] = None
|
||||
iv: Optional[str] = None
|
||||
9
core/routers/auth/services.py
Normal file
9
core/routers/auth/services.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/17 下午8:14
|
||||
# @File : services.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
6
core/routers/card/__init__.py
Normal file
6
core/routers/card/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午3:59
|
||||
# @File : __init__.py.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
220
core/routers/card/routes.py
Normal file
220
core/routers/card/routes.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午4:51
|
||||
# @File : routes.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
import logging
|
||||
from typing import Literal, List
|
||||
|
||||
import ujson
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from . import schema
|
||||
from .scraper import card_scraper
|
||||
from config import settings
|
||||
from core.base import schema as base_schema
|
||||
from core.db.crud import CRUD
|
||||
from core.db.engine import get_async_session, get_cache
|
||||
from core.db.models import Card, SearchCardCache, UserCollection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
card_router = APIRouter(prefix='/card', tags=['卡册模块'])
|
||||
|
||||
|
||||
@card_router.get('/search', response_model=base_schema.BaseResponse[schema.CardOut])
|
||||
async def get_card_search(
|
||||
cert_number: str = Query(pattern='[a-zA-Z0-9]+'),
|
||||
platform: base_schema.SupportPlatformType = Query(...),
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
redis_conn: aioredis.Redis = Depends(get_cache)
|
||||
):
|
||||
logger.info(f'Get card search request: {cert_number}')
|
||||
logger.info(platform)
|
||||
result = None
|
||||
# 缓存
|
||||
cache_keyname = f'card_book:card_search:{platform.value}:{cert_number}'
|
||||
result_str = await redis_conn.get(cache_keyname)
|
||||
if result_str is not None:
|
||||
result = ujson.decode(result_str)
|
||||
return {'data': result}
|
||||
|
||||
db_crud = CRUD(db_session, SearchCardCache)
|
||||
# 缓存库
|
||||
if result is None:
|
||||
card = await db_crud.get(filters={'cert_number': cert_number, 'source': platform.value})
|
||||
if card is not None:
|
||||
result = card.data
|
||||
|
||||
# 采集
|
||||
if result is None:
|
||||
scraper_result = await card_scraper.choice(platform.value, cert_number)
|
||||
if scraper_result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='相关卡片信息未找到'
|
||||
)
|
||||
result = scraper_result['data']
|
||||
await db_crud.create(
|
||||
data={
|
||||
**scraper_result,
|
||||
'source': result.get('source') or platform.value,
|
||||
},
|
||||
filters={'cert_number': cert_number, 'source': platform.value}
|
||||
)
|
||||
await redis_conn.set(cache_keyname, ujson.encode(result), ex=settings.REDIS_CACHE_EXPIRATION_SECONDS)
|
||||
|
||||
return {'data': result}
|
||||
|
||||
|
||||
@card_router.get(
|
||||
'/me/all',
|
||||
response_model=base_schema.BaseResponse[
|
||||
base_schema.Paginated[
|
||||
List[schema.CardOut]
|
||||
]
|
||||
],
|
||||
)
|
||||
async def get_me_all_card(
|
||||
page: int = Query(default=1, gt=0),
|
||||
page_size: int = Query(default=15, gt=0, le=200),
|
||||
category: Literal['all', 'anime', 'sports', 'other', 'displayed'] = Query(default='all'),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
user_id = None
|
||||
conditions = [
|
||||
UserCollection.user_id == user_id,
|
||||
]
|
||||
if category in ['anime', 'sports', 'other']:
|
||||
conditions.append(Card.category == category)
|
||||
|
||||
if category == 'displayed':
|
||||
conditions.append(UserCollection.displayed is True)
|
||||
|
||||
total_stmt = (
|
||||
select(func.count()).select_from(UserCollection).filter(
|
||||
*conditions
|
||||
)
|
||||
)
|
||||
total = await db_session.execute(total_stmt)
|
||||
stmt = (
|
||||
select(UserCollection, Card)
|
||||
.join(Card, UserCollection.card_id == Card.id)
|
||||
.where(*conditions)
|
||||
.order_by(UserCollection.created_at.desc()).limit(page_size).offset((page - 1) * page_size)
|
||||
)
|
||||
records = await db_session.execute(stmt)
|
||||
|
||||
return {
|
||||
'data': {
|
||||
'list': [record.dict() for record in records.scalars().all()],
|
||||
'total': total.scalar(),
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@card_router.get(
|
||||
'/visit/{user_id}/all',
|
||||
response_model=base_schema.BaseResponse[
|
||||
base_schema.Paginated[
|
||||
List[schema.CardOut]
|
||||
]
|
||||
],
|
||||
)
|
||||
async def get_visit_user_all_card(
|
||||
user_id: str,
|
||||
page: int = Query(default=1, gt=0),
|
||||
page_size: int = Query(default=15, gt=0, le=200),
|
||||
# category: Literal['displayed'] = Query(default='displayed'),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
conditions = [
|
||||
UserCollection.user_id == user_id, UserCollection.displayed is True
|
||||
]
|
||||
total_stmt = (
|
||||
select(func.count()).select_from(UserCollection).filter(*conditions)
|
||||
)
|
||||
total = await db_session.execute(total_stmt)
|
||||
stmt = (
|
||||
select(UserCollection, Card)
|
||||
.join(Card, UserCollection.card_id == Card.id)
|
||||
.where(*conditions)
|
||||
.order_by(UserCollection.created_at.desc()).limit(page_size).offset((page - 1) * page_size)
|
||||
)
|
||||
records = await db_session.execute(stmt)
|
||||
|
||||
return {
|
||||
'data': {
|
||||
'list': [record.dict() for record in records.scalars().all()],
|
||||
'total': total.scalar(),
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@card_router.post('/create', response_model=base_schema.BaseResponse[schema.CardOut])
|
||||
async def created_card(
|
||||
payload: schema.Card,
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
db_crud = CRUD(db_session, Card)
|
||||
card: Card = await db_crud.create(
|
||||
data={
|
||||
**payload.dict(exclude_none=True),
|
||||
'create_user_id': None,
|
||||
'source': payload.source.value,
|
||||
},
|
||||
filters={'cert_number': payload.cert_number, 'source': payload.source.value}
|
||||
)
|
||||
return {'data': card}
|
||||
|
||||
|
||||
@card_router.post('/{card_id}/update/')
|
||||
async def update_card_price_info(
|
||||
card_id: int,
|
||||
payload: schema.UpdateCard,
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
db_crud = CRUD(db_session, UserCollection)
|
||||
result = await db_crud.update(
|
||||
data=payload.dict(exclude_none=True),
|
||||
filters={'card_id': card_id, 'user_id': None}
|
||||
)
|
||||
return {'message': 'success' if result is not None else 'fail'}
|
||||
|
||||
|
||||
@card_router.post('/{card_id}/display/')
|
||||
async def display_card_status(
|
||||
card_id: int,
|
||||
status: bool,
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
db_crud = CRUD(db_session, UserCollection)
|
||||
result = await db_crud.update(
|
||||
data={
|
||||
'displayed': status
|
||||
},
|
||||
filters={'card_id': card_id, 'user_id': None}
|
||||
)
|
||||
return {'message': 'success' if result is not None else 'fail'}
|
||||
|
||||
|
||||
@card_router.delete('/delete/', response_model=base_schema.BaseResponse)
|
||||
async def delete_card(
|
||||
payload: schema.DeleteCardIn,
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
db_crud = CRUD(db_session, Card)
|
||||
state = await db_crud.delete(filters={'card_id_in': payload.card_ids})
|
||||
|
||||
if state is False:
|
||||
db_crud = CRUD(db_session, UserCollection)
|
||||
state = await db_crud.delete(filters={'card_id_in': payload.card_ids, 'user_id': None})
|
||||
|
||||
return {'message': 'success' if state else 'fail'}
|
||||
41
core/routers/card/schema.py
Normal file
41
core/routers/card/schema.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午5:01
|
||||
# @File : schema.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, conint
|
||||
|
||||
from core.base.schema import SupportPlatformType, PlatformSourceType
|
||||
|
||||
|
||||
class Card(BaseModel):
|
||||
cert_number: str
|
||||
card_year: conint(gt=0, le=2099)
|
||||
card_brand: Optional[str] = None
|
||||
card_score: Optional[float] = None
|
||||
auto_score: Optional[float] = None
|
||||
card_name: str
|
||||
card_number: Optional[str] = None
|
||||
card_type: Optional[str] = None
|
||||
card_picture: Optional[dict] = {}
|
||||
card_spare_picture: Optional[dict] = {}
|
||||
source: SupportPlatformType
|
||||
|
||||
|
||||
class UpdateCard(BaseModel):
|
||||
purchase_price: float
|
||||
display_price: float
|
||||
|
||||
|
||||
class CardOut(Card, UpdateCard):
|
||||
card_year: int
|
||||
source: PlatformSourceType
|
||||
purchase_price: Optional[float] = None
|
||||
display_price: Optional[float] = None
|
||||
|
||||
|
||||
class DeleteCardIn(BaseModel):
|
||||
card_ids: list[int]
|
||||
470
core/routers/card/scraper.py
Normal file
470
core/routers/card/scraper.py
Normal file
@@ -0,0 +1,470 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/14 上午10:34
|
||||
# @File : scraper.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
import logging
|
||||
|
||||
from curl_cffi import requests
|
||||
from fake_useragent import UserAgent
|
||||
from parsel import Selector
|
||||
from simple_spider_tool import jsonpath, regx_match, format_json
|
||||
|
||||
from config import settings
|
||||
from core.base.exceptions import SearchCardNotFound, SearchCardRetry
|
||||
from core.utils.helper import async_retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SiteApi:
|
||||
def __init__(self):
|
||||
self.user_agent = UserAgent(
|
||||
browsers=['Google', 'Chrome', 'Firefox', 'Edge', 'Safari'],
|
||||
os=['Windows', 'Mac OS X'],
|
||||
platforms=['desktop'],
|
||||
min_version=127.0
|
||||
)
|
||||
self.session = requests.AsyncSession()
|
||||
self.headers = {
|
||||
'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0",
|
||||
'Accept-Encoding': "gzip, deflate, br, zstd",
|
||||
'Sec-Fetch-Site': "same-origin",
|
||||
'Sec-Fetch-Mode': "cors",
|
||||
'Sec-Fetch-Dest': "empty",
|
||||
'Accept-Language': "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
}
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method,
|
||||
url,
|
||||
headers=None,
|
||||
params=None,
|
||||
data=None,
|
||||
json=None,
|
||||
use_abroad_proxy: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
headers = {
|
||||
**self.headers,
|
||||
'User-Agent': self.user_agent.random,
|
||||
**(headers or {}),
|
||||
}
|
||||
# 境外代理
|
||||
if use_abroad_proxy:
|
||||
kwargs['proxies'] = {
|
||||
'http': settings.OVERSEAS_PROXY,
|
||||
'https': settings.OVERSEAS_PROXY
|
||||
}
|
||||
r = await self.session.request(
|
||||
method, url, headers=headers, params=params, data=data, json=json, **kwargs
|
||||
)
|
||||
logger.debug(f'耗时:{url} {r.elapsed}')
|
||||
return r
|
||||
|
||||
async def get(self, url, headers=None, params=None, **kwargs):
|
||||
return await self.request('GET', url, headers=headers, params=params, **kwargs)
|
||||
|
||||
async def post(self, url, headers=None, params=None, data=None, json=None, **kwargs):
|
||||
return await self.request(
|
||||
'POST', url, headers=headers, params=params, data=data, json=json, **kwargs
|
||||
)
|
||||
|
||||
@async_retry(exceptions=(SearchCardRetry,))
|
||||
async def psa(self, cert_number: str | int):
|
||||
"""PSA"""
|
||||
url = f'https://www.psacard.com/cert/{cert_number}'
|
||||
headers = {
|
||||
# 'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': 'https://www.psacard.com',
|
||||
'Referer': f'https://www.psacard.com/cert/{cert_number}',
|
||||
}
|
||||
r = await self.get(url, headers=headers, use_abroad_proxy=True)
|
||||
chck_string = 'Just a moment...'
|
||||
if chck_string in r.text:
|
||||
raise SearchCardRetry(chck_string)
|
||||
return r
|
||||
|
||||
async def psa_images(self, cert_number: str | int, images_id: str | int):
|
||||
"""PSA图片"""
|
||||
url = 'https://www.psacard.com/GetPSACertImages'
|
||||
headers = {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': 'https://www.psacard.com',
|
||||
'Referer': f'https://www.psacard.com/cert/{cert_number}',
|
||||
}
|
||||
payload = {
|
||||
'certID': images_id
|
||||
}
|
||||
return await self.post(url, headers=headers, params=payload, use_abroad_proxy=True)
|
||||
|
||||
async def bgs(self, cert_number: str | int):
|
||||
"""BGS"""
|
||||
url = 'https://www.beckett.com/api/grading/lookup'
|
||||
params = {
|
||||
'category': 'BGS',
|
||||
'serialNumber': cert_number
|
||||
}
|
||||
r = await self.get(url, headers=self.headers, params=params, use_abroad_proxy=True)
|
||||
return r
|
||||
|
||||
async def cgc(self, cert_number: str | int):
|
||||
"""CGC"""
|
||||
url = f'https://www.cgccards.com/certlookup/{cert_number}/'
|
||||
headers = {
|
||||
'referer': 'https://www.cgccards.com/'
|
||||
}
|
||||
return await self.get(url, headers=headers)
|
||||
|
||||
async def gbtc(self, cert_number: str | int):
|
||||
"""北京公博"""
|
||||
url = 'https://wapi.gongbocoins.com/gbca/orderCoin/getWebsiteRatingInfo'
|
||||
headers = {
|
||||
'Content-Type': "application/json;charset=UTF-8",
|
||||
'origin': 'https://www.gongbocoins.com',
|
||||
'referer': "https://www.gongbocoins.com"
|
||||
}
|
||||
payload = {
|
||||
'ratingCode': cert_number
|
||||
}
|
||||
return await self.post(url, headers=headers, json=payload)
|
||||
|
||||
async def bctc(self, cert_number: str | int):
|
||||
"""南京保粹"""
|
||||
url = f'https://web-api.baocuicoin.com/Search/index?keyword={cert_number}&code'
|
||||
headers = {
|
||||
'origin': 'https://www.baocuicoin.com/',
|
||||
'referer': "https://www.baocuicoin.com/"
|
||||
}
|
||||
r = await self.post(url, headers=headers)
|
||||
return r
|
||||
|
||||
async def ccg(self, cert_number: str | int):
|
||||
"""重庆藏卡"""
|
||||
url = 'https://webapi.cangcard.com/webapi/card/num_list'
|
||||
payload = {
|
||||
'num': cert_number
|
||||
}
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Referer': "https://servicewechat.com/wx0b020dffc7cf7e74/75/page-frame.html",
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090c1f)XWEB/11581'
|
||||
}
|
||||
r = await self.post(url, headers=headers, json=payload)
|
||||
return r
|
||||
|
||||
async def close(self):
|
||||
await self.session.close()
|
||||
|
||||
|
||||
class CardScraper:
|
||||
def __init__(self):
|
||||
self.api = SiteApi()
|
||||
self._handlers = None
|
||||
|
||||
@property
|
||||
def handlers(self):
|
||||
if self._handlers is None:
|
||||
self._handlers = {
|
||||
'psa': self.psa,
|
||||
'bgs': self.bgs,
|
||||
'cgc': self.cgc,
|
||||
'gbtc': self.gbtc,
|
||||
'ccg': self.ccg,
|
||||
'bctc': self.bctc,
|
||||
}
|
||||
return self._handlers
|
||||
|
||||
async def psa(self, cert_number: str | int) -> dict | None:
|
||||
"""PSA"""
|
||||
r = await self.api.psa(cert_number)
|
||||
html = Selector(r.text)
|
||||
if html.xpath('//div[contains(@class, "alert-danger")]/p[contains(text(), "not found")]').get() is not None:
|
||||
return
|
||||
|
||||
info_elements = html.xpath('//main[@id="mainContent"]/div/table//tr')
|
||||
info = dict()
|
||||
for info_element in info_elements:
|
||||
name = info_element.xpath('./th[1]').xpath('string()').get()
|
||||
name = name.lower().replace(' ', '_').replace('/', '_')
|
||||
value = info_element.xpath('./td[1]').xpath('string()').get()
|
||||
info[name.strip()] = value.strip()
|
||||
picture_elements = info_elements.xpath('.//img')
|
||||
info['pictures'] = [
|
||||
{
|
||||
'name': picture_element.xpath('./@alt').get(),
|
||||
'value': picture_element.xpath('./@src').get(),
|
||||
} for picture_element in picture_elements
|
||||
]
|
||||
|
||||
item = dict(cert_number=None)
|
||||
# 评级卡号
|
||||
item['cert_number'] = info['certification_number']
|
||||
# 年份
|
||||
item['card_year'] = info.get('year')
|
||||
# 卡片品牌
|
||||
item['card_brand'] = info.get('brand')
|
||||
# 卡片评分
|
||||
card_grade = info.get('card_grade') or ''
|
||||
item['card_score'] = regx_match(r'(\d+)', card_grade, first=True)
|
||||
# 签字评分
|
||||
auto_score = info.get('autograph_grade') or ''
|
||||
item['auto_score'] = regx_match(r'(\d+)', auto_score, first=True)
|
||||
# 卡片名称
|
||||
item['card_name'] = info.get('player')
|
||||
# 卡片编号
|
||||
item['card_number'] = info.get('card_number')
|
||||
# 卡片类型
|
||||
item['card_type'] = info.get('sport')
|
||||
images_id = html.xpath(
|
||||
'//script[contains(text(), "getPSACertImages")]/text()'
|
||||
).re_first(r'getPSACertImages\(\'(\d+)\'\);')
|
||||
|
||||
if images_id:
|
||||
r = await self.api.psa_images(cert_number, images_id)
|
||||
result = r.json()
|
||||
# 卡片正、反图片(远程)
|
||||
item['card_spare_picture'] = {
|
||||
'front_picture': jsonpath(result, '$.PSACertImages[0].OriginalImageUrl', first=True),
|
||||
'reverse_picture': jsonpath(result, '$.PSACertImages[1].OriginalImageUrl', first=True)
|
||||
}
|
||||
# 来源
|
||||
item['source'] = 'psa'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': info,
|
||||
}
|
||||
|
||||
async def bgs(self, cert_number: str | int) -> dict | None:
|
||||
r = await self.api.bgs(cert_number)
|
||||
if r.status_code != 200:
|
||||
return
|
||||
result = r.json()
|
||||
|
||||
item = dict(cert_number=None)
|
||||
# 评级卡号
|
||||
item['cert_number'] = result.get('item_id')
|
||||
# 年份
|
||||
set_name = result.get('set_name') or ''
|
||||
item['card_year'] = regx_match(r'(\d{,4}).*', set_name, first=True)
|
||||
# 卡片品牌
|
||||
item['card_brand'] = result.get('sport_name')
|
||||
# 卡片评分
|
||||
item['card_score'] = result.get('final_grade')
|
||||
# 签字评分
|
||||
auto_score = result.get('autograph_grade')
|
||||
item['auto_score'] = auto_score if auto_score != '0.0' else None
|
||||
# 卡片名称
|
||||
item['card_name'] = result.get('player_name')
|
||||
# 卡片编号 无
|
||||
# 卡片类型 无
|
||||
# 卡片正、反图片(远程) 无
|
||||
# 来源
|
||||
item['source'] = 'bgs'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result,
|
||||
}
|
||||
|
||||
async def cgc(self, cert_number: str | int):
|
||||
"""CGC"""
|
||||
r = await self.api.cgc(cert_number)
|
||||
html = Selector(r.text)
|
||||
if html.xpath('//div[@class="error" and @ng-show]').get() is None:
|
||||
return
|
||||
|
||||
info_elements = html.xpath('//div[@class="content-wrapper"]//dl')
|
||||
info = dict()
|
||||
for info_element in info_elements:
|
||||
name = info_element.xpath('./dt[1]').xpath('string()').get()
|
||||
name = name.lower().replace('#', '').strip().replace(' ', '_')
|
||||
value = info_element.xpath('./dd[1]').xpath('string()').get()
|
||||
info[name.strip()] = value.strip()
|
||||
|
||||
item = dict(cert_number=None)
|
||||
# 评级卡号
|
||||
item['cert_number'] = info.get('cert')
|
||||
# 年份
|
||||
item['card_year'] = info.get('year')
|
||||
# 卡片品牌
|
||||
item['card_brand'] = info.get('game')
|
||||
# 卡片评分
|
||||
item['card_score'] = regx_match(r'(\d+)', info.get('grade') or '', first=True)
|
||||
# 签字评分 无
|
||||
# 卡片名称
|
||||
item['card_name'] = info.get('card_name')
|
||||
# 卡片编号
|
||||
item['card_number'] = info.get('card_number')
|
||||
# 卡片类型 无
|
||||
# 卡片正、反图片(远程)
|
||||
picture_query = '//div[@class="certlookup-images"]/div[@class="certlookup-images-item"][{index}]//img/@src'
|
||||
item['card_spare_picture'] = {
|
||||
'front_picture': html.xpath(picture_query.format(index=1)).get(),
|
||||
'reverse_picture': html.xpath(picture_query.format(index=2)).get()
|
||||
}
|
||||
# 来源
|
||||
item['source'] = 'cgc'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': info,
|
||||
}
|
||||
|
||||
async def gbtc(self, cert_number: str | int) -> dict | None:
|
||||
"""北京公博"""
|
||||
r = await self.api.gbtc(cert_number)
|
||||
result = r.json()
|
||||
if result.get('errorCode') != 0:
|
||||
return
|
||||
|
||||
attrs = jsonpath(result, '$.data.attr', first=True)
|
||||
attrs_string = ';'.join(attrs)
|
||||
|
||||
item = dict(cert_number=None)
|
||||
# 评级卡号
|
||||
item['cert_number'] = jsonpath(result, '$.data.ratingCode', first=True)
|
||||
# 年份
|
||||
item['card_year'] = regx_match(r'年份:(\d{,4})', attrs_string, first=True)
|
||||
# 卡片品牌
|
||||
item['card_brand'] = regx_match(r'发行商:(.*?);', attrs_string, first=True)
|
||||
# 卡片评分
|
||||
item['card_score'] = jsonpath(result, '$.data.goodsScore', first=True)
|
||||
# 签字评分
|
||||
item['auto_score'] = regx_match(r'签字分数:(\d+)', attrs_string, first=True)
|
||||
# 卡片名称
|
||||
item['card_name'] = jsonpath(result, '$.data.goodsName', first=True)
|
||||
# 卡片编号
|
||||
item['card_number'] = regx_match(r'卡片编码:(.*?);', attrs_string, first=True)
|
||||
# 卡片类型 无
|
||||
# 卡片正、反图片(远程)
|
||||
item['card_spare_picture'] = {
|
||||
'front_picture': jsonpath(result, '$.data.imgList[0].self', first=True),
|
||||
'reverse_picture': jsonpath(result, '$.data.imgList[1].self', first=True)
|
||||
}
|
||||
# 来源
|
||||
item['source'] = 'gbtc'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result.get('data'),
|
||||
}
|
||||
|
||||
async def ccg(self, cert_number: str | int) -> dict | None:
|
||||
"""重庆藏卡"""
|
||||
r = await self.api.ccg(cert_number)
|
||||
result = r.json()
|
||||
if result.get('code') != '200':
|
||||
return
|
||||
|
||||
item = dict(cert_number=None)
|
||||
# 评级卡号
|
||||
item['cert_number'] = jsonpath(result, '$.data.num', first=True)
|
||||
# 年份
|
||||
item['card_year'] = jsonpath(result, '$.data.year', first=True)
|
||||
# 卡片品牌
|
||||
content = jsonpath(result, '$.data.content', first=True)
|
||||
item['card_brand'] = regx_match(r'\d{4}(.*?)\.', content, first=True)
|
||||
# 卡片评分
|
||||
item['card_score'] = jsonpath(result, '$.data.zm_num', first=True)
|
||||
# 签字评分 无
|
||||
# 卡片名称
|
||||
item['card_name'] = jsonpath(result, '$.data.name', first=True)
|
||||
# 卡片类型 无
|
||||
# 卡片编号
|
||||
item['card_number'] = content
|
||||
# 卡片正、反图片(远程)
|
||||
item['card_spare_picture'] = {
|
||||
'front_picture': jsonpath(result, '$.data.img_z', first=True),
|
||||
'reverse_picture': jsonpath(result, '$.data.img_f', first=True)
|
||||
}
|
||||
# 来源
|
||||
item['source'] = 'ccg'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result.get('data'),
|
||||
}
|
||||
|
||||
async def bctc(self, cert_number: str | int) -> dict | None:
|
||||
"""南京保粹"""
|
||||
r = await self.api.bctc(cert_number)
|
||||
result = r.json()
|
||||
if result.get('code') != '200':
|
||||
return
|
||||
|
||||
details = jsonpath(result, '$.info.details', first=True)
|
||||
|
||||
item = dict(cert_number=None)
|
||||
# 评级卡号
|
||||
item['cert_number'] = jsonpath(details, '$.grade[?(@.title =="证书编号")].val', first=True)
|
||||
# 年份
|
||||
year_string = jsonpath(details, '$.grade[?(@.title =="年份")].val', first=True)
|
||||
item['card_year'] = year = regx_match(r'(\d{,4})', year_string, first=True)
|
||||
# 卡片品牌
|
||||
item['card_brand'] = year_string.replace(f'{year}', '').strip() if year_string else None
|
||||
# 卡片评分
|
||||
item['card_score'] = jsonpath(details, '$.grade[?(@.title =="分值")].val', first=True)
|
||||
# 签字评分
|
||||
item['auto_score'] = jsonpath(details, '$.grade[?(@.title =="签字分值")].val', first=True)
|
||||
# 卡片名称
|
||||
item['card_name'] = jsonpath(details, '$.grade[?(@.title =="名称")].val', first=True)
|
||||
# 卡片编号
|
||||
item['card_number'] = jsonpath(details, '$.grade[?(@.title =="编号")].val', first=True)
|
||||
# 卡片类型 无
|
||||
# 卡片正、反图片(远程)
|
||||
item['card_spare_picture'] = {
|
||||
'front_picture': jsonpath(details, '$.info.picurl', first=True),
|
||||
'reverse_picture': jsonpath(details, '$.info.dt_pics', first=True)
|
||||
}
|
||||
# 来源
|
||||
item['source'] = 'bctc'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result.get('info'),
|
||||
}
|
||||
|
||||
async def choice(self, platform: str, cert_number: str | int) -> dict | None:
|
||||
handler = self.handlers.get(platform.lower())
|
||||
if handler is None:
|
||||
raise Exception('平台不支持')
|
||||
return await handler(cert_number)
|
||||
|
||||
async def close(self):
|
||||
await self.api.close()
|
||||
|
||||
|
||||
card_scraper = CardScraper()
|
||||
|
||||
|
||||
async def main():
|
||||
import time
|
||||
started = time.time()
|
||||
print(format_json(await card_scraper.choice('psa', '83610272')))
|
||||
print(time.time() - started)
|
||||
# print(format_json(await card_scraper.choice('psa', '83610272')))
|
||||
# print(format_json(await card_scraper.psa('83610272')))
|
||||
# print(format_json(await card_scraper.cgc('6011456021')))
|
||||
# print(format_json(await card_scraper.bgs('16770585')))
|
||||
# print(format_json(await card_scraper.gbtc('8110071983')))
|
||||
# print(format_json(await card_scraper.bctc('E000033353')))
|
||||
# print(format_json(await card_scraper.ccg('230810139152')))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
if sys.platform == 'win32':
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
asyncio.run(main())
|
||||
18
core/routers/card/services.py
Normal file
18
core/routers/card/services.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/17 上午1:00
|
||||
# @File : services.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.db.models import SearchCardCache
|
||||
|
||||
|
||||
async def get_cache_card_by_number(db_session: AsyncSession, *, cert_number: str) -> SearchCardCache | None:
|
||||
query = select(SearchCardCache).where(SearchCardCache.cert_number == cert_number)
|
||||
result = await db_session.execute(query)
|
||||
card: SearchCardCache | None = result.scalar_one_or_none()
|
||||
|
||||
return card
|
||||
6
core/routers/file/__init__.py
Normal file
6
core/routers/file/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午4:01
|
||||
# @File : __init__.py.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc : 文件存储模块
|
||||
76
core/routers/file/routes.py
Normal file
76
core/routers/file/routes.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午4:50
|
||||
# @File : routes.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, status, HTTPException, Depends
|
||||
from PIL import Image
|
||||
|
||||
from config import settings
|
||||
from . import schema
|
||||
from core.base.schema import BaseResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
file_router = APIRouter(prefix='/file', tags=['文件存储模块'])
|
||||
|
||||
|
||||
@file_router.post('/{upload_type}/upload', response_model=BaseResponse[schema.FileOut])
|
||||
async def upload_file(
|
||||
upload_type: Literal['card', 'chat', 'avatar'],
|
||||
file: UploadFile = File(...)
|
||||
):
|
||||
# 验证文件类型
|
||||
if file.content_type not in settings.UPLOADED_ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail=f'Unsupported media type: {file.content_type}'
|
||||
)
|
||||
|
||||
# 验证文件大小
|
||||
if file.size > settings.UPLOADED_MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File exceeds maximum size'
|
||||
)
|
||||
|
||||
try:
|
||||
file_chunks = await file.read()
|
||||
file_hash = hashlib.sha256(file_chunks).hexdigest()
|
||||
file_name = f'{file_hash}.jpg'
|
||||
dirname_args = [settings.UPLOADED_FILES_DIR, upload_type.lower()]
|
||||
if upload_type.lower() != 'card':
|
||||
dirname_args.append('test')
|
||||
|
||||
save_path = os.path.join(*dirname_args)
|
||||
save_file_path = os.path.join(*dirname_args, file_name)
|
||||
if os.path.exists(save_file_path) is False:
|
||||
# 创建目录
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
|
||||
img = Image.open(BytesIO(file_chunks))
|
||||
img = img.convert("RGB")
|
||||
img.save(save_file_path, format="JPEG")
|
||||
|
||||
file_info = {
|
||||
'filename': file_name,
|
||||
'content_type': file.content_type,
|
||||
'size': os.path.getsize(save_path),
|
||||
'hash': file_hash,
|
||||
'url': save_file_path
|
||||
}
|
||||
return {'data': file_info}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Upload failed: {str(e)}')
|
||||
logger.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'File upload failed'
|
||||
)
|
||||
14
core/routers/file/schema.py
Normal file
14
core/routers/file/schema.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午5:01
|
||||
# @File : schema.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FileOut(BaseModel):
|
||||
filename: str
|
||||
content_type: str
|
||||
size: int
|
||||
hash: str
|
||||
6
core/routers/im/__init__.py
Normal file
6
core/routers/im/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午1:03
|
||||
# @File : __init__.py.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
33
core/routers/im/routes.py
Normal file
33
core/routers/im/routes.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午4:53
|
||||
# @File : routes.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
im_router = APIRouter(prefix='/im', tags=['聊天模块'])
|
||||
|
||||
|
||||
# @im_router.get('/chat/{user_id}/messages/')
|
||||
# def get_single_chat_messages(
|
||||
# user_id: int,
|
||||
# limit: Annotated[int, Query(ge=1, le=200)] = 20,
|
||||
# cursor: int = -1,
|
||||
# ):
|
||||
# pass
|
||||
#
|
||||
#
|
||||
# @im_router.get('/chats/messages/')
|
||||
# def get_chats_messages(
|
||||
# limit: Annotated[int, Query(ge=1, le=200)] = 100,
|
||||
# cursor: int = -1,
|
||||
# ):
|
||||
# pass
|
||||
#
|
||||
#
|
||||
# @im_router.websocket('/chat')
|
||||
# def get_chat():
|
||||
# pass
|
||||
6
core/routers/user/__init__.py
Normal file
6
core/routers/user/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午4:01
|
||||
# @File : __init__.py.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
9
core/routers/user/routes.py
Normal file
9
core/routers/user/routes.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/13 下午4:53
|
||||
# @File : routes.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from fastapi import APIRouter
|
||||
|
||||
user_router = APIRouter(prefix='/user', tags=['用户模块'])
|
||||
Reference in New Issue
Block a user