BUFF选品扫货系统是一个用于网易BUFF平台的CSGO饰品自动化交易工具,主要功能包括:
BUFF/
├── BuffMain.py # 主程序入口和核心逻辑
├── BUFFAPI.py # BUFF平台API封装
├── BUFFXP.py # 选品参数检查和构建
├── InitDB.py # 数据库初始化
├── CategoryDialog.py # 品类管理对话框
├── QualityDialog.py # 品质管理对话框
├── TypeDialog.py # 类别管理对话框
├── AppearanceDialog.py # 外观管理对话框
├── CollectibleDialog.py # 收藏品管理对话框
├── SelectLogDialog.py # 选品日志管理
├── EditSelectLogDialog.py # 编辑选品日志对话框
├── tail_xprz.py # 选品日志监控脚本
├── tail_shrz.py # 扫货日志监控脚本
├── tail_gmrz.py # 购买日志监控脚本
├── buff.db # SQLite数据库
├── xppcids.txt # 需剔除的商品ID列表
├── 选品日志log/ # 选品日志目录
├── 扫货日志log/ # 扫货日志目录
├── 购买日志log/ # 购买日志目录
├── 详细日志log/ # 详细日志目录
└── 选品日志文件/ # 选品结果导出目录
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_number TEXT NOT NULL, -- 手机号码
email TEXT NOT NULL, -- 邮箱
email_alias TEXT NOT NULL, -- 邮箱别名/特殊代码
cookie TEXT NOT NULL -- 登录Cookie (支持多个,用换行分隔)
)
CREATE TABLE sh_account_info (
phone TEXT, -- 手机号码
email TEXT, -- 邮箱
code TEXT, -- 特殊代码
cookie TEXT, -- 登录Cookie
xcsrftoken TEXT -- CSRF Token
)
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- 品类名称
code TEXT NOT NULL, -- 品类编码 (BUFF平台定义)
selected BOOLEAN NOT NULL DEFAULT 0 -- 是否选中
)
示例数据: 手枪(weapon_pistol)、步枪(weapon_rifle)、刀具(weapon_knife)等
CREATE TABLE qualities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, -- 品质编码
name TEXT NOT NULL, -- 品质名称
selected BOOLEAN NOT NULL DEFAULT 0
)
示例数据: 隐秘(unusual)、保密(classified)、受限(restricted)等
CREATE TABLE types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, -- 类别编码
name TEXT NOT NULL, -- 类别名称
selected BOOLEAN NOT NULL DEFAULT 0
)
示例数据: 纪念品(tournament)、StatTrak™(strange)等
CREATE TABLE appearances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, -- 外观编码
name TEXT NOT NULL, -- 外观名称
selected BOOLEAN NOT NULL DEFAULT 0
)
示例数据: 崭新出厂(factory_new)、略有磨损(minimal_wear)、久经沙场(field_tested)等
CREATE TABLE collectibles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, -- 收藏品编码
name TEXT NOT NULL, -- 收藏品名称
selected BOOLEAN NOT NULL DEFAULT 0
)
CREATE TABLE config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
min_price REAL, -- 最低价格
max_price REAL, -- 最高价格
sort_mode TEXT, -- 排序模式: 默认|价格升序|价格降序|在售数量升序
stop_condition TEXT, -- 停止条件: 按照成功数量|按照扫描数量
success_count INTEGER, -- 成功数量配置
scan_count INTEGER, -- 扫描商品数量
page_size INTEGER, -- 每页请求数量
pause_time INTEGER, -- 接口暂停时间(秒)
max_pages INTEGER, -- 最大请求页数
ref_price_adjust INTEGER, -- 参考价调整系数(百分比)
sweep_price_adjust INTEGER, -- 扫货价调整系数(百分比)
needed_msg_check_pause_time INTEGER, -- 需短信验证时暂停时长(秒)
needed_msg_check_try_times INTEGER, -- 需短信验证时重试次数
delete_ids TEXT, -- 需剔除的商品ID列表(#号分割)
cron_time TEXT, -- 定时执行时间配置(如: 9:00,15:30)
sell_num INTEGER -- 最低销售数量筛选
)
CREATE TABLE select_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL, -- 商品ID
item_name TEXT NOT NULL, -- 商品名称
min_wear TEXT NOT NULL, -- 最低磨损值
max_wear TEXT NOT NULL, -- 最高磨损值
min_full_wear_price REAL NOT NULL, -- 全磨损区间最低价
sweep_price REAL NOT NULL, -- 扫货价(购买底价)
judge_price REAL NOT NULL, -- 判断价(参考价)
timestamp TEXT NOT NULL -- 时间戳
)
CREATE TABLE shpz_settings (
pause_time REAL, -- API请求暂停时间(秒)
thread_count INTEGER, -- 扫货线程数
needed_msg_check_pause_time INTEGER, -- 需短信验证时暂停时长
needed_msg_check_try_times INTEGER -- 需短信验证时重试次数
)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36",
"Host": "buff.163.com",
"Referer": "https://buff.163.com/market/?game=csgo",
"X-Requested-With": "XMLHttpRequest",
"Cookie": "<登录Cookie>"
}
OK: 请求成功Captcha Validate Required: 需要短信验证Login Required: 需要登录Action Forbidden: 操作被禁止System Error: 系统错误(通常是访问频繁)接口: GET https://buff.163.com/api/market/goods
请求参数:
{
"game": "csgo",
"category": "weapon_pistol,weapon_rifle", // 品类(多个用逗号分隔)
"category_group": "weapon_pistol,weapon_rifle", // 品类组
"rarity": "unusual,classified", // 品质
"quality": "tournament,strange", // 类别
"exterior": "factory_new,minimal_wear", // 外观
"itemset": "", // 收藏品
"min_price": "10", // 最低价
"max_price": "500", // 最高价
"sort_by": "price.asc", // 排序: price.asc|price.desc|sell_num.desc
"page_size": "80", // 每页数量(默认80,最大可能是100)
"page_num": "1" // 页码
}
响应示例:
{
"code": "OK",
"data": {
"items": [
{
"id": "968993", // 商品ID
"name": "AK-47 | 红线 (略有磨损)",
"price": "168.50",
"sell_num": 150, // 在售数量
// ... 其他字段
}
],
"total_page": 50, // 总页数
"total_count": 1000 // 总数量
}
}
接口: GET https://buff.163.com/api/market/goods/info
请求参数:
?goods_id=968993
响应示例:
{
"code": "OK",
"data": {
"paintwear_choices": [
["0.07", "0.15"], // 崭新出厂
["0.15", "0.38"], // 略有磨损
["0.38", "0.45"], // 久经沙场
// ... 其他磨损区间
]
}
}
接口: GET https://buff.163.com/api/market/goods/sell_order
请求参数:
?game=csgo
&page_num=1
&sort_by=price.asc
&goods_id=968993
&min_paintwear=0.07 // 可选: 最低磨损
&max_paintwear=0.15 // 可选: 最高磨损
响应示例:
{
"code": "OK",
"data": {
"items": [
{
"id": "sell_order_id_123456", // 订单ID
"goods_id": "968993",
"price": "170.50", // 价格
"paintwear": "0.0823", // 磨损值
// ... 其他字段
}
]
}
}
扫货专用请求参数:
?game=csgo
&allow_epay=1 // 允许电子支付
&allow_tradable_cooldown=1 // 允许交易冷却期
&page_num=1
&page_size=95 // 扫货时固定为95(硬编码)
&sort_by=price.asc // 价格升序
&goods_id=968993
&min_price=0
&max_price=173.40 // 扫货价上限
&min_paintwear=0.07 // 磨损下限
&max_paintwear=0.15 // 磨损上限
注意:
page_size=95https://buff.163.com/api/market/goods/sell_order?game=csgo&allow_epay=1&allow_tradable_cooldown=1&page_num=1&page_size=95&sort_by=price.asc&goods_id=接口: POST https://buff.163.com/api/market/goods/buy
请求头额外字段:
headers = {
# ... 其他字段
"Referer": f"https://buff.163.com/goods/{goods_id}?from=market",
"Cookie": "<登录Cookie>",
"x-csrftoken": "<从响应头set-cookie中获取的csrf_token>"
}
请求体:
{
"game": "csgo",
"goods_id": "968993",
"sell_order_id": "sell_order_id_123456",
"price": "170.50",
"pay_method": 1,
"allow_tradable_cooldown": 0,
"token": "",
"cdkey_id": "",
"hide_non_epay": false
}
响应示例:
{
"code": "OK",
"data": {
"id": "bill_order_id_789", // 订单ID
// ... 其他字段
}
}
1. 参数校验
├─ 检查Cookie是否为空
├─ 获取筛选条件(品类/品质/类别/外观/收藏品)
├─ 获取价格范围、排序方式、停止条件等配置
└─ 读取剔除ID列表(xppcids.txt)
2. 暂停扫货进程
└─ 设置扫货停止标志,等待扫货线程退出
3. 保存并清空上次选品结果
├─ 导出选品日志到文件(选品日志文件/YYYYMMDDHHMMSS_选品日志文件.json)
├─ 清空数据库select_logs表
└─ 清空界面选品日志列表
4. 初始化选品状态
├─ 重置已选品数量(searchedGoodsNum = 0)
├─ 重置已选品成功数量(search_success_count = 0)
├─ 初始化请求时间戳(last_xp_request_time = 0)
└─ 构建商品查询参数
5. 主循环(循环2次,xiesi_tag < 2)
每次循环执行以下步骤:
5.1 初始化本轮数据
├─ 清空待检查商品列表(need_check_goods_list)
├─ 初始化Cookie状态池(cookie_status = {})
└─ 分割Cookie文本为列表(用换行分隔)
5.2 检查停止条件
├─ 检查停止选品标志
├─ 检查成功数量是否达标
└─ 检查扫描数量是否达标
5.3 分页查询商品列表
├─ 循环请求最多max_pages页
├─ Cookie轮询机制(支持多个Cookie轮流使用)
├─ 异常重试机制
│ ├─ System Error: 重试,超过次数跳过
│ └─ Captcha Validate Required: 切换Cookie,超过次数标记Cookie失效
└─ 将商品添加到待检查列表
5.4 商品去重和筛选
├─ 根据商品ID去重(使用字典去重)
├─ 剔除黑名单商品(xppcids.txt,用#号分割)
└─ 根据最低销售数量筛选(sell_num >= check_sell_num)
5.5 遍历商品进行价格分析
对每个商品执行以下步骤:
5.5.1 获取商品磨损区间信息
├─ 调用getPaintwearChoices接口
├─ Cookie轮询和异常处理
└─ 获取paintwear_choices数组
5.5.2 获取全磨损区间最低价(min_price)
├─ 调用sell_order接口(不带磨损参数)
├─ Cookie轮询和异常处理
└─ 提取最低价格
5.5.3 获取各磨损区间最低价
├─ 遍历磨损区间,调用sell_order接口
├─ 每个区间都进行Cookie轮询和异常处理
└─ 构建min_price_list数组 [{min_wear, max_wear, min_price}]
5.5.4 价格计算与筛选
├─ 计算判断价: pd_price = round(round(min_price * (100 + ref_price_adjust) / 100, 2) + 0.01, 2)
├─ 筛选磨损区间: 只保留 区间最低价 >= pd_price 的区间
├─ 阶梯价格筛选: 保留递减的价格阶梯(价格必须递减,否则剔除后续区间)
└─ 计算扫货价: sweep_price = round(round(min_price * (100 + sweep_price_adjust) / 100, 2) + 0.01, 2)
5.5.5 保存选品结果
├─ 插入数据库select_logs表
├─ 通过信号槽添加到界面选品日志列表
├─ 记录选品日志(xp_logger)
└─ 选品成功数量+1
5.6 更新选品数量
└─ 每处理一个商品,searchedGoodsNum += 1
6. 恢复扫货进程
└─ 选品完成后自动调用beginSH()启动扫货
重要说明:
(page/goods_index + retry_count) % len(cookie_list)的方式轮询Cookiecookie_status字典中,达到阈值后从可用列表移除示例: 假设某商品全磨损最低价为170元
参数配置:
ref_price_adjust = 10 (参考价调整系数10%)sweep_price_adjust = 2 (扫货价调整系数2%)计算过程:
1. 全磨损最低价: min_price = 170.00
2. 判断价(pd_price) - 使用双重round保证精度:
第一步: 170.00 * (100 + 10) / 100 = 187.00
第二步: round(187.00, 2) = 187.00
第三步: 187.00 + 0.01 = 187.01
第四步: round(187.01, 2) = 187.01
代码: pd_price = round(round(min_price * (100 + ref_price_adjust) / 100, 2) + 0.01, 2)
结果: 187.01元
3. 扫货价(sweep_price) - 使用双重round保证精度:
第一步: 170.00 * (100 + 2) / 100 = 173.40
第二步: round(173.40, 2) = 173.40
第三步: 173.40 + 0.01 = 173.41
第四步: round(173.41, 2) = 173.41
代码: sweep_price = round(round(min_price * (100 + sweep_price_adjust) / 100, 2) + 0.01, 2)
结果: 173.41元
注意事项:
- 使用双重round是为了避免浮点数精度问题
- 第一次round保证百分比计算后的精度
- 加0.01是为了确保价格略高于临界值
- 第二次round保证最终结果为两位小数
4. 各磨损区间最低价:
假设API返回:
- 区间1 [0.00-0.07]: 270元
- 区间2 [0.07-0.15]: 260元
- 区间3 [0.15-0.38]: 230元
- 区间4 [0.38-0.45]: 240元
- 区间5 [0.45-1.00]: 170元
5. 筛选逻辑:
步骤1: 过滤低于判断价(187.01)的区间
- 区间1: 270 >= 187.01 ✓
- 区间2: 260 >= 187.01 ✓
- 区间3: 230 >= 187.01 ✓
- 区间4: 240 >= 187.01 ✓ (但后续会被阶梯筛选剔除)
- 区间5: 170 < 187.01 ✗ (剔除)
步骤2: 阶梯价格筛选(保持递减)
- 区间1: 270
- 区间2: 260 < 270 ✓ (递减)
- 区间3: 230 < 260 ✓ (递减)
- 区间4: 240 > 230 ✗ (不递减,剔除区间4及之后)
最终保留区间: [0.00-0.38]
即: 区间1-3
6. 选品结果:
- 商品ID: 968993
- 磨损范围: 0.00 - 0.38
- 全磨损最低价: 170.00
- 扫货价: 173.41 (扫货时,低于此价格才购买)
- 判断价: 187.01
业务逻辑解释:
多Cookie轮询:
# Cookie池
cookie_text_list = [
"cookie1",
"cookie2",
"cookie3"
]
# Cookie状态记录(记录异常次数)
cookie_status = {
"cookie1": 0, # 正常
"cookie2": 1, # 异常1次
"cookie3": 3 # 异常3次,即将被移除
}
# 轮询逻辑
current_cookie = cookie_text_list[(page + temp_cookie_i) % len(cookie_text_list)]
# Cookie失效处理
if cookie_status[cookie] >= needed_msg_check_try_times:
cookie_text_list.remove(cookie) # 从池中移除
# 全部失效时退出
if len(cookie_text_list) == 0:
# 退出选品
异常处理策略:
System Error: Cookie正常,但访问频繁,暂停后重试Captcha Validate Required: Cookie需验证,切换下一个Cookieneeded_msg_check_try_times次时,标记为失效1. 参数校验
├─ 检查Cookie是否为空
└─ 获取扫货配置(暂停时间、线程数等)
2. 暂停选品进程
└─ 设置选品停止标志,等待选品线程退出
3. 启动多线程扫货
├─ 根据配置启动N个扫货线程
└─ 每个线程独立执行扫货逻辑
4. 线程扫货逻辑(每个线程循环执行)
├─ 遍历选品日志列表
├─ 线程间任务分配(线程1处理1,4,7... 线程2处理2,5,8...)
├─ 对每个任务:
│ ├─ 调用getGoodsInfo获取可购买订单列表
│ ├─ 筛选符合价格和磨损条件的订单
│ ├─ 遍历订单,调用购买接口
│ └─ 记录购买日志
└─ 异常处理(重试/切换Cookie等)
5. 停止扫货
└─ 设置停止标志,各线程检测后退出
# 假设有10条选品记录,3个扫货线程
select_logs = [record0, record1, ..., record9]
thread_count = 3
# 线程1 (thread_num=1): 处理索引 0, 3, 6, 9
# 线程2 (thread_num=2): 处理索引 1, 4, 7
# 线程3 (thread_num=3): 处理索引 2, 5, 8
for i in range(len(select_logs)):
if i % thread_count == thread_num - 1:
# 处理这条记录
1. 构建查询URL
base_url + goods_id + min_price=0 + max_price=sweep_price
+ min_paintwear + max_paintwear
2. 调用sell_order接口
└─ page_size=95 (扫货时使用较大值)
3. 异常处理
├─ Captcha Validate Required: 重试,超过次数退出线程
├─ Login Required: 重试,超过次数退出线程
└─ System Error: 跳过,继续下一个
4. 解析响应
├─ 提取csrf_token (从set-cookie响应头)
└─ 返回items列表(符合条件的订单)
1. 构建购买请求
├─ 请求体: goods_id, sell_order_id, price等
└─ 请求头: Cookie, x-csrftoken
2. 调用购买接口
└─ POST /api/market/goods/buy
3. 异常处理
├─ Captcha Validate Required: 重试,超过次数退出
└─ 其他错误: 记录日志,继续下一个
4. 成功处理
├─ 记录购买日志(gm_logger)
└─ 暂停配置的时间后继续
[时间戳] 消息内容详细日志log/详细日志.txt时间戳 - 消息内容选品日志log/选品日志.txt时间戳 - 消息内容典型记录:
[选品] 正在使用:cookie1 名称:AK-47 | 红线,ID:968993
[已选] 正在使用:cookie1. 名称:AK-47 | 红线,ID:968993,购买底价:173.41,区间:0.07-0.38
扫货日志log/扫货日志.txt典型记录:
扫货线程-1 名称:AK-47 | 红线,ID:968993,购买底价:173.41,区间:0.07-0.38
扫货线程-1 共获取到5个商品可购买
购买日志log/购买日志.txt典型记录:
名称:AK-47 | 红线,ID:968993,购买底价:173.41,区间:0.07-0.38,购买价格:170.50
详细日志.txt # 当天日志
详细日志.2025-10-08.txt # 历史日志(按天归档)
tail_xprz.py: 实时监控选品日志tail_shrz.py: 实时监控扫货日志tail_gmrz.py: 实时监控购买日志配置项:
TreeWidget列:
操作按钮:
导出格式:
{
"968993": {
"price": "173.41",
"min_paintwear": "0.07",
"max_paintwear": "0.38",
"name": "AK-47 | 红线 (略有磨损)",
"商品ID": "968993",
"商品名称": "AK-47 | 红线 (略有磨损)",
"磨损最低值": "0.07",
"磨损最高值": "0.38",
"全磨损区间最低价": "170.00",
"扫货价": "173.41",
"判断价": "187.01",
"时间": "2025-10-08 10:30:00"
}
}
# 支持多个Cookie,用换行分隔
cookie_text = """
cookie1_value
cookie2_value
cookie3_value
"""
cookie_text_list = cookie_text.split('\n')
# 轮流使用
current_index = (request_count + retry_count) % len(cookie_text_list)
current_cookie = cookie_text_list[current_index]
# 主流程中记录上次请求时间
self.last_xp_request_time = 0
# 请求前检查间隔
current_time = time.time()
time_since_last = current_time - self.last_xp_request_time
if time_since_last < float(self.pause_time):
wait_time = float(self.pause_time) - time_since_last
self.detail_logger.debug(f"暂停 {wait_time:.2f} seconds before making the next request.")
time.sleep(wait_time)
# 发起请求
response = requests.get(url, headers=headers)
# 更新时间
self.last_xp_request_time = time.time()
在BUFFAPI.py中,不同类型的API有独立的请求时间控制:
class BUFFAPI:
# 磨损信息API的请求时间控制
@staticmethod
def getPaintwearChoices(self_buff, goods_id):
current_time = time.time()
time_since_last_request = current_time - self_buff.last_paintwear_request_time
if time_since_last_request < float(self_buff.pause_time):
wait_time = float(self_buff.pause_time) - time_since_last_request
time.sleep(float(wait_time))
# ... API调用
self_buff.last_paintwear_request_time = time.time()
# 价格查询API的请求时间控制
@staticmethod
def getPaintwearChoicesMinPrice(self, goods_id, min_paint_wear, max_paint_wear):
current_time = time.time()
time_since_last_request = current_time - self.last_minprice_request_time
if time_since_last_request < float(self.pause_time):
wait_time = float(self.pause_time) - time_since_last_request
time.sleep(float(wait_time))
# ... API调用
self.last_minprice_request_time = time.time()
关键点:
retry_count = 0
max_retries = 3
while retry_count < max_retries:
response = requests.get(url)
if response.json()['code'] == 'OK':
break # 成功,退出
elif response.json()['code'] == 'System Error':
time.sleep(30) # 等待更长时间
continue
elif response.json()['code'] == 'Captcha Validate Required':
retry_count += 1
time.sleep(pause_time)
# 切换Cookie
else:
break # 其他错误,退出
if retry_count >= max_retries:
# 达到最大重试次数,标记Cookie失效或退出
response = requests.get(url, headers=headers)
# 从响应头获取
for header, value in response.headers.items():
if header == 'set-cookie':
cookies_list = value.split(', ')
for cookie in cookies_list:
if 'csrf_token' in cookie:
csrf_token = cookie.split('=')[1].split(';')[0]
# 用于后续POST请求
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()
# 支持多个时间点
cron_time = "9:00,15:30,21:00"
cron_times = cron_time.split(',')
for cron_time_item in cron_times:
if ':' in cron_time_item:
hour, minute = cron_time_item.split(':')
scheduler.add_job(
select_goods_function,
'cron',
hour=int(hour),
minute=int(minute),
second=0
)
scheduler.start()
# 启动多个线程
for i in range(1, thread_count + 1):
thread = threading.Thread(target=scan_goods, args=(i,))
thread.start()
# 每个线程处理不同的商品
def scan_goods(thread_num):
for i in range(len(select_logs)):
if i % thread_count == thread_num - 1:
# 处理这条记录
goods_id = select_logs[i]['item_id']
# ... 购买逻辑
from PyQt5.QtCore import pyqtSignal, pyqtSlot
class MainWindow(QMainWindow):
# 定义信号
log_added = pyqtSignal(str, str, str, str, str, str, str, str)
def __init__(self):
# 连接信号到槽函数
self.log_added.connect(self._add_select_log)
def select_goods_thread(self):
# 在工作线程中发射信号
self.log_added.emit(
item_id, item_name, min_wear, max_wear,
min_price, sweep_price, judge_price, timestamp
)
@pyqtSlot(str, str, str, str, str, str, str, str)
def _add_select_log(self, item_id, item_name, ...):
# 在主线程中更新UI
item = QTreeWidgetItem(self.tree_widget)
item.setText(1, item_id)
# ...
net/httpcmd/
└─ main.go # 程序入口
internal/
├─ api/ # API层
│ ├─ handler/ # HTTP处理器
│ └─ middleware/ # 中间件
├─ service/ # 业务逻辑层
│ ├─ select_service.go # 选品服务
│ └─ scan_service.go # 扫货服务
├─ repository/ # 数据访问层
│ ├─ config_repo.go
│ └─ select_log_repo.go
├─ model/ # 数据模型
│ ├─ config.go
│ └─ select_log.go
├─ client/ # 外部API客户端
│ └─ buff_client.go
└─ utils/ # 工具函数
pkg/ # 可复用的包
configs/ # 配置文件
logs/ # 日志目录
web/ # 前端资源(如果使用Web)
type SelectService struct {
buffClient *BuffClient
configRepo *ConfigRepository
selectLogRepo *SelectLogRepository
logger *zap.Logger
}
func (s *SelectService) StartSelect(ctx context.Context) error {
// 1. 加载配置
config, err := s.configRepo.GetConfig()
// 2. 构建查询参数
params := s.buildQueryParams(config)
// 3. 查询商品列表
goods, err := s.buffClient.GetGoodsList(ctx, params)
// 4. 分析商品价格
for _, item := range goods {
select {
case <-ctx.Done():
return ctx.Err() // 支持取消
default:
result, err := s.analyzeGoodsPrice(ctx, item)
if err != nil {
continue
}
// 5. 保存选品结果
s.selectLogRepo.Save(result)
}
}
return nil
}
type ScanService struct {
buffClient *BuffClient
selectLogRepo *SelectLogRepository
logger *zap.Logger
}
func (s *ScanService) StartScan(ctx context.Context, threadCount int) error {
// 1. 获取选品日志
logs, err := s.selectLogRepo.GetAll()
// 2. 创建任务通道
taskChan := make(chan *SelectLog, len(logs))
for _, log := range logs {
taskChan <- log
}
close(taskChan)
// 3. 启动多个worker
var wg sync.WaitGroup
for i := 0; i < threadCount; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
s.scanWorker(ctx, workerID, taskChan)
}(i)
}
// 4. 等待所有worker完成
wg.Wait()
return nil
}
func (s *ScanService) scanWorker(ctx context.Context, id int, tasks <-chan *SelectLog) {
for task := range tasks {
select {
case <-ctx.Done():
return
default:
// 获取可购买订单
orders, err := s.buffClient.GetSellOrders(ctx, task)
// 购买
for _, order := range orders {
s.buffClient.BuyGoods(ctx, order)
}
}
}
}
type CookieManager struct {
cookies []string
currentIndex int
failureCounts map[string]int
maxRetries int
mu sync.RWMutex
}
func (cm *CookieManager) GetCookie() (string, error) {
cm.mu.RLock()
defer cm.mu.RUnlock()
if len(cm.cookies) == 0 {
return "", errors.New("no available cookies")
}
cookie := cm.cookies[cm.currentIndex]
cm.currentIndex = (cm.currentIndex + 1) % len(cm.cookies)
return cookie, nil
}
func (cm *CookieManager) MarkFailure(cookie string) {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.failureCounts[cookie]++
if cm.failureCounts[cookie] >= cm.maxRetries {
// 移除失效的Cookie
cm.removeCookie(cookie)
}
}
func (cm *CookieManager) MarkSuccess(cookie string) {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.failureCounts[cookie] = 0
}
# config.yaml
app:
name: "BUFF选品扫货系统"
version: "2.0.0"
database:
type: "sqlite"
path: "./buff.db"
buff:
base_url: "https://buff.163.com"
user_agent: "Mozilla/5.0..."
select:
pause_time: 5
max_pages: 10
ref_price_adjust: 10
sweep_price_adjust: 2
scan:
pause_time: 3
thread_count: 3
logging:
level: "info"
output: ["stdout", "file"]
file_path: "./logs/app.log"
max_size: 100 # MB
max_age: 30 # days
// 定义业务错误
var (
ErrCookieRequired = errors.New("cookie is required")
ErrCookieInvalid = errors.New("cookie is invalid")
ErrCaptchaRequired = errors.New("captcha validation required")
ErrGoodsNotFound = errors.New("goods not found")
ErrInsufficientBalance = errors.New("insufficient balance")
)
// 统一错误处理
func HandleBuffAPIError(code string) error {
switch code {
case "OK":
return nil
case "Captcha Validate Required":
return ErrCaptchaRequired
case "Login Required":
return ErrCookieInvalid
case "System Error":
return errors.New("buff system error")
default:
return fmt.Errorf("unknown error: %s", code)
}
}
// 支持取消和超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
// 在select和scan中传递context
err := selectService.StartSelect(ctx)
func (c *BuffClient) GetWithRetry(ctx context.Context, url string, maxRetries int) (*Response, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
resp, err := c.Get(ctx, url)
if err == nil {
return resp, nil
}
if errors.Is(err, ErrCaptchaRequired) {
// 切换Cookie
c.cookieManager.MarkFailure(c.currentCookie)
c.currentCookie, _ = c.cookieManager.GetCookie()
time.Sleep(time.Duration(c.config.PauseTime) * time.Second)
continue
}
lastErr = err
}
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}
logger.Info("开始选品",
zap.Int("max_pages", config.MaxPages),
zap.Int("page_size", config.PageSize),
zap.String("sort_mode", config.SortMode),
)
logger.Info("商品分析完成",
zap.String("goods_id", goods.ID),
zap.String("goods_name", goods.Name),
zap.Float64("min_price", result.MinPrice),
zap.Float64("sweep_price", result.SweepPrice),
zap.String("wear_range", fmt.Sprintf("%s-%s", result.MinWear, result.MaxWear)),
)
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o buff-tool cmd/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/buff-tool .
COPY --from=builder /app/configs ./configs
CMD ["./buff-tool"]
version: '3.8'
services:
buff-tool:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/root/data
- ./logs:/root/logs
environment:
- APP_ENV=production
// 使用GORM自动迁移
db.AutoMigrate(
&model.Account{},
&model.Config{},
&model.SelectLog{},
// ...
)
// 或手动执行SQL
sqlFile, _ := ioutil.ReadFile("schema.sql")
db.Exec(string(sqlFile))
# Python: 从数据库读取
cursor.execute("SELECT * FROM config LIMIT 1")
config = cursor.fetchone()
# 导出为YAML
import yaml
config_dict = {
'select': {
'min_price': config[1],
'max_price': config[2],
# ...
}
}
with open('config.yaml', 'w') as f:
yaml.dump(config_dict, f)
保留原有日志目录结构,Go版本直接写入相同目录
func TestPriceCalculation(t *testing.T) {
service := &SelectService{}
minPrice := 170.0
refAdjust := 10
sweepAdjust := 2
judgePrice := service.calculateJudgePrice(minPrice, refAdjust)
sweepPrice := service.calculateSweepPrice(minPrice, sweepAdjust)
assert.Equal(t, 187.01, judgePrice)
assert.Equal(t, 173.41, sweepPrice)
}
func TestSelectFlow(t *testing.T) {
// 使用测试数据库
db := setupTestDB()
defer db.Close()
// Mock BUFF API
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": "OK",
"data": testGoodsData,
})
}))
defer server.Close()
// 执行选品
service := NewSelectService(db, server.URL)
err := service.StartSelect(context.Background())
assert.NoError(t, err)
// 验证结果
logs := service.selectLogRepo.GetAll()
assert.Greater(t, len(logs), 0)
}
// Cookie加密存储
import "golang.org/x/crypto/bcrypt"
func EncryptCookie(cookie string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(cookie), bcrypt.DefaultCost)
return string(hash), err
}
// 环境变量
os.Setenv("BUFF_COOKIE", "...")
cookie := os.Getenv("BUFF_COOKIE")
import "golang.org/x/time/rate"
limiter := rate.NewLimiter(rate.Every(5*time.Second), 1)
func (c *BuffClient) Get(url string) {
limiter.Wait(context.Background())
// 发起请求
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: false, // 验证证书
},
},
}
// 批量插入
tx := db.Begin()
for _, log := range logs {
tx.Create(&log)
}
tx.Commit()
// 索引
db.Exec("CREATE INDEX idx_goods_id ON select_logs(item_id)")
// 使用worker pool
type WorkerPool struct {
tasks chan Task
workers int
wg sync.WaitGroup
}
func (p *WorkerPool) Start() {
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker()
}
}
func (p *WorkerPool) worker() {
defer p.wg.Done()
for task := range p.tasks {
task.Execute()
}
}
import "github.com/patrickmn/go-cache"
// 缓存商品磨损信息(5分钟过期)
c := cache.New(5*time.Minute, 10*time.Minute)
c.Set(goodsID, paintwearData, cache.DefaultExpiration)
if data, found := c.Get(goodsID); found {
return data.(PaintwearData)
}
Q: 为什么选品价格计算要使用双重round和加0.01? A:
round(round(170.00 * 1.10, 2) + 0.01, 2) = 187.01Q: 为什么选品要循环2次(xiesi_tag < 2)? A: 增加选品机会,因为第一次查询可能由于各种原因(Cookie异常、API限制等)没有获取到足够的商品,第二次循环可以补充更多商品
Q: 为什么扫货线程要分配任务而不是抢占式?
A: 保证每个商品都被处理,避免某些商品被遗漏。使用i % thread_count == thread_num - 1的方式可以均匀分配任务
Q: Cookie为什么要轮询使用? A: 单个账号请求频繁容易触发验证码,多账号轮询降低风险。异常的Cookie会被标记并最终从可用列表中移除
Q: 为什么需要判断价和扫货价两个价格? A:
Q: 为什么需要维护多个独立的请求时间戳? A:
last_xp_request_time: 主流程请求时间last_paintwear_request_time: 磨损信息API请求时间last_minprice_request_time: 价格查询API请求时间Q: 为什么价格列表要进行阶梯筛选? A: 确保磨损越好价格越高,符合市场规律。如果出现价格不递减的情况(如区间3价格230,区间4价格240),说明数据异常,需要剔除后续区间
Q: xppcids.txt文件是如何使用的? A:
Q: 扫货时为什么page_size固定为95? A:
Captcha Validate RequiredOK: 正常System Error: 访问频繁,建议暂停30秒Captcha Validate Required: 需要验证,切换CookieLogin Required: Cookie失效,需要重新登录Action Forbidden: 操作被禁止(可能是风控)选品配置:
pause_time: 5-10 # API请求间隔(秒),建议5-10秒
page_size: 80-100 # 每页数量,建议80
max_pages: 10-20 # 最大页数,根据需求调整
ref_price_adjust: 10-15 # 参考价调整系数(%),建议10-15
sweep_price_adjust: 2-5 # 扫货价调整系数(%),建议2-5
needed_msg_check_pause_time: 30-60 # 验证码暂停时长(秒)
needed_msg_check_try_times: 3-5 # 验证码重试次数
扫货配置:
pause_time: 3-5 # 扫货请求间隔(秒)
thread_count: 1-3 # 扫货线程数,建议1-3个
page_size: 95 # 固定值,硬编码
Cookie管理:
# 磨损外观对应的磨损值区间
FACTORY_NEW = "factory_new" # 崭新出厂: 0.00-0.07
MINIMAL_WEAR = "minimal_wear" # 略有磨损: 0.07-0.15
FIELD_TESTED = "field_tested" # 久经沙场: 0.15-0.38
WELL_WORN = "well_worn" # 破损不堪: 0.38-0.45
BATTLE_SCARRED = "battle_scarred" # 战痕累累: 0.45-1.00
SORT_DEFAULT = "default" # 默认排序
SORT_PRICE_ASC = "price.asc" # 价格升序
SORT_PRICE_DESC = "price.desc" # 价格降序
SORT_SELL_NUM_DESC = "sell_num.desc" # 在售数量降序
# 选品相关URL
GOODS_LIST_URL = "https://buff.163.com/api/market/goods"
GOODS_INFO_URL = "https://buff.163.com/api/market/goods/info"
SELL_ORDER_URL = "https://buff.163.com/api/market/goods/sell_order"
# 扫货专用URL(硬编码参数)
SCAN_URL = "https://buff.163.com/api/market/goods/sell_order?game=csgo&allow_epay=1&allow_tradable_cooldown=1&page_num=1&page_size=95&sort_by=price.asc&goods_id="
# 购买URL
BUY_URL = "https://buff.163.com/api/market/goods/buy"
# 请求控制
DEFAULT_PAUSE_TIME = 5 # 默认暂停时间(秒)
DEFAULT_PAGE_SIZE = 80 # 默认每页数量
DEFAULT_MAX_PAGES = 10 # 默认最大页数
SCAN_PAGE_SIZE = 95 # 扫货固定每页数量
# 价格调整
DEFAULT_REF_ADJUST = 10 # 默认参考价调整(%)
DEFAULT_SWEEP_ADJUST = 2 # 默认扫货价调整(%)
# 重试控制
DEFAULT_RETRY_TIMES = 3 # 默认重试次数
DEFAULT_RETRY_PAUSE = 30 # 默认重试暂停时间(秒)
# 日志保留
LOG_BACKUP_COUNT = 30 # 日志保留天数
round(round(min_price * (100 + adjust) / 100, 2) + 0.01, 2)// Go语言实现双重round
func calculatePrice(basePrice float64, adjustPercent int, extraCent float64) float64 {
// 第一次计算并round
step1 := basePrice * float64(100 + adjustPercent) / 100.0
step2 := math.Round(step1*100) / 100 // round到2位小数
// 加0.01
step3 := step2 + extraCent
// 第二次round
result := math.Round(step3*100) / 100
return result
}
// 使用示例
judgePrice := calculatePrice(170.0, 10, 0.01) // 187.01
sweepPrice := calculatePrice(170.0, 2, 0.01) // 173.41
// 筛选价格递减的区间
func filterPriceList(priceList []PriceRange, judgePrice float64) []PriceRange {
var filtered []PriceRange
// 步骤1: 过滤低于判断价的区间
for _, item := range priceList {
if item.MinPrice >= judgePrice {
filtered = append(filtered, item)
} else {
break // 后续区间都不符合,直接退出
}
}
// 步骤2: 保持价格递减
var result []PriceRange
for i, item := range filtered {
if i == 0 {
result = append(result, item)
continue
}
// 检查是否递减
if item.MinPrice < result[i-1].MinPrice {
result = append(result, item)
} else {
break // 不递减,剔除后续所有区间
}
}
return result
}
type CookiePool struct {
cookies []string
status map[string]int
currentIndex int
maxRetries int
mu sync.RWMutex // 读写锁
}
func (cp *CookiePool) GetNext() (string, error) {
cp.mu.Lock()
defer cp.mu.Unlock()
if len(cp.cookies) == 0 {
return "", errors.New("no available cookies")
}
cookie := cp.cookies[cp.currentIndex]
cp.currentIndex = (cp.currentIndex + 1) % len(cp.cookies)
return cookie, nil
}
func (cp *CookiePool) MarkFailure(cookie string) {
cp.mu.Lock()
defer cp.mu.Unlock()
cp.status[cookie]++
if cp.status[cookie] >= cp.maxRetries {
cp.removeCookie(cookie)
}
}
func (s *SelectService) StartSelect(ctx context.Context) error {
// 外层循环2次
for xiesiTag := 0; xiesiTag < 2; xiesiTag++ {
// 初始化本轮数据
needCheckList := []Goods{}
cookieStatus := make(map[string]int)
// 检查停止条件
if s.shouldStop() {
break
}
// 查询商品列表
for page := 1; page <= s.config.MaxPages; page++ {
goods, err := s.getGoodsPage(ctx, page)
if err != nil {
continue
}
needCheckList = append(needCheckList, goods...)
}
// 去重和筛选
uniqueGoods := s.deduplicateGoods(needCheckList)
filteredGoods := s.filterGoods(uniqueGoods)
// 分析价格
for _, goods := range filteredGoods {
s.analyzeGoods(ctx, goods)
}
}
return nil
}
func readExcludeIDs(filepath string) ([]string, error) {
data, err := ioutil.ReadFile(filepath)
if err != nil {
return nil, err
}
// 按行分割
lines := strings.Split(string(data), "\n")
var ids []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
ids = append(ids, line)
}
}
return ids, nil
}
// 使用集合过滤
func filterExcludeGoods(goods []Goods, excludeIDs []string) []Goods {
// 转换为map提高查询效率
excludeSet := make(map[string]bool)
for _, id := range excludeIDs {
excludeSet[id] = true
}
var filtered []Goods
for _, g := range goods {
if !excludeSet[g.ID] {
filtered = append(filtered, g)
}
}
return filtered
}
文档版本: v1.1
最后更新: 2025-10-08
作者: AI Assistant
适用版本: Python原版 -> Go重构版
更新内容: