|
@@ -4,6 +4,10 @@
|
|
|
# Date : 2026/3/17 14:57
|
|
# Date : 2026/3/17 14:57
|
|
|
import time
|
|
import time
|
|
|
import inspect
|
|
import inspect
|
|
|
|
|
+import hashlib
|
|
|
|
|
+import random
|
|
|
|
|
+import string
|
|
|
|
|
+from datetime import datetime, timedelta
|
|
|
import requests
|
|
import requests
|
|
|
import schedule
|
|
import schedule
|
|
|
import user_agent
|
|
import user_agent
|
|
@@ -19,9 +23,49 @@ logger.add("./logs/{time:YYYYMMDD}.log", encoding='utf-8', rotation="00:00",
|
|
|
https://pokecolor.cn/h5/pages-card/card/history
|
|
https://pokecolor.cn/h5/pages-card/card/history
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
-headers = {
|
|
|
|
|
- "user-agent": user_agent.generate_user_agent()
|
|
|
|
|
-}
|
|
|
|
|
|
|
+SIGNATURE_SECRET = "pk_code_a_b_4321_sc"
|
|
|
|
|
+
|
|
|
|
|
+# 单页条数(与接口 page_size 参数保持一致)
|
|
|
|
|
+PAGE_SIZE = 20
|
|
|
|
|
+# 未登录(匿名)时服务端把已售列表锁死在前 600 条:第 1~30 页正常,
|
|
|
|
|
+# 第 31 页起返回的是第 30 页的重复数据(next=False)。故匿名场景最多翻到第 30 页。
|
|
|
|
|
+MAX_ANON_PAGE = 30
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_x_r_content():
|
|
|
|
|
+ alphabet = string.ascii_letters + string.digits
|
|
|
|
|
+ return "".join(random.choice(alphabet) for _ in range(15)) + random.choice("ABCDEF")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_verify_token(method, timestamp, x_r_content, params):
|
|
|
|
|
+ rounds = int(x_r_content[-1], 16)
|
|
|
|
|
+ param_string = "&".join(
|
|
|
|
|
+ f"{key}={value}"
|
|
|
|
|
+ for key, value in sorted(params.items())
|
|
|
|
|
+ if value is not None and value != ""
|
|
|
|
|
+ )
|
|
|
|
|
+ token_source = f"{method.upper()}{timestamp}{x_r_content}{param_string}{SIGNATURE_SECRET}"
|
|
|
|
|
+ for _ in range(rounds):
|
|
|
|
|
+ token_source = hashlib.md5(token_source.encode("utf-8")).hexdigest()
|
|
|
|
|
+ return token_source
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_headers(method, params):
|
|
|
|
|
+ timestamp = str(int(time.time() * 1000))
|
|
|
|
|
+ x_r_content = build_x_r_content()
|
|
|
|
|
+ return {
|
|
|
|
|
+ "accept": "application/json, text/plain, */*",
|
|
|
|
|
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
|
|
|
+ "origin": "https://pokecolor.cn",
|
|
|
|
|
+ "Paytype": "kl",
|
|
|
|
|
+ "Platform": "web",
|
|
|
|
|
+ "referer": "https://pokecolor.cn/",
|
|
|
|
|
+ "user-agent": user_agent.generate_user_agent(),
|
|
|
|
|
+ "v": "1.11.0.481",
|
|
|
|
|
+ "X-R-Content": x_r_content,
|
|
|
|
|
+ "X-Timestamp": timestamp,
|
|
|
|
|
+ "X-Verify-Token": build_verify_token(method, timestamp, x_r_content, params),
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
|
|
|
def after_log(retry_state):
|
|
def after_log(retry_state):
|
|
@@ -81,17 +125,22 @@ def get_sold_single_page(log, page_num=1):
|
|
|
"order_by": "-order_deal_on",
|
|
"order_by": "-order_deal_on",
|
|
|
"status_filter": "sold_finish"
|
|
"status_filter": "sold_finish"
|
|
|
}
|
|
}
|
|
|
- response = requests.get(url, headers=headers, params=params, proxies=get_proxys(log), timeout=22)
|
|
|
|
|
|
|
+ response = requests.get(url, headers=build_headers("GET", params), params=params, proxies=get_proxys(log), timeout=22)
|
|
|
data = response.json()
|
|
data = response.json()
|
|
|
|
|
|
|
|
if data.get("code") == 100 and data.get("msg") == "ok":
|
|
if data.get("code") == 100 and data.get("msg") == "ok":
|
|
|
- results = data.get("data", {}).get("results", [])
|
|
|
|
|
- total_count = data.get("data", {}).get("count", 0)
|
|
|
|
|
- log.info(f"Successfully fetched page {page_num} with {len(results)} items")
|
|
|
|
|
- return results, total_count
|
|
|
|
|
- else:
|
|
|
|
|
- log.error(f"Error fetching page {page_num}: {data}")
|
|
|
|
|
- return None, None
|
|
|
|
|
|
|
+ payload = data.get("data", {}) or {}
|
|
|
|
|
+ results = payload.get("results", []) or []
|
|
|
|
|
+ total_count = payload.get("count", 0)
|
|
|
|
|
+ has_next = payload.get("next", False)
|
|
|
|
|
+ log.info(f"Successfully fetched page {page_num} with {len(results)} items, next={has_next}")
|
|
|
|
|
+ return results, total_count, has_next
|
|
|
|
|
+
|
|
|
|
|
+ # 非 100 一律抛出,交给 tenacity 重试。
|
|
|
|
|
+ # 注意:接口限频时会返回 code=197、data="参数错误"(用「参数错误」伪装限流),
|
|
|
|
|
+ # 并非真的参数错,直接重试/退避即可,不能当作正常结束而 break。
|
|
|
|
|
+ raise RuntimeError(
|
|
|
|
|
+ f"page {page_num} 返回异常 code={data.get('code')} msg={data.get('msg')} data={data.get('data')}")
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_list_results(log, results, sql_pool):
|
|
def parse_list_results(log, results, sql_pool):
|
|
@@ -155,45 +204,91 @@ def parse_list_results(log, results, sql_pool):
|
|
|
return parsed_orders
|
|
return parsed_orders
|
|
|
|
|
|
|
|
|
|
|
|
|
-def get_sold_list(log, sql_pool):
|
|
|
|
|
|
|
+def parse_deal_date(order):
|
|
|
|
|
+ """从订单里取成交日期(仅年月日)。
|
|
|
|
|
+
|
|
|
|
|
+ 结果为 page 内最旧一条与 cutoff 比较用,接口按 -order_deal_on 倒序返回,
|
|
|
|
|
+ 故一页里最后一条即最旧一条。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ order (dict): 单条订单数据。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ datetime.date: 成交日期;取不到或解析失败时返回 None。
|
|
|
"""
|
|
"""
|
|
|
- 获取已售列表数据
|
|
|
|
|
|
|
+ time_str = order.get("order_deal_on")
|
|
|
|
|
+ if not time_str:
|
|
|
|
|
+ return None
|
|
|
|
|
+ try:
|
|
|
|
|
+ # order_deal_on 形如 2026-07-19T07:31:05.142972+08:00,取 T 前的日期部分即可
|
|
|
|
|
+ return datetime.strptime(time_str.split("T")[0], "%Y-%m-%d").date()
|
|
|
|
|
+ except (ValueError, AttributeError, IndexError):
|
|
|
|
|
+ return None
|
|
|
|
|
|
|
|
- :param log: logger对象
|
|
|
|
|
- :param sql_pool: 数据库连接池对象
|
|
|
|
|
|
|
+
|
|
|
|
|
+def get_sold_list(log, sql_pool, days_back=1):
|
|
|
|
|
+ """增量采集已售列表。
|
|
|
|
|
+
|
|
|
|
|
+ 按成交时间倒序从第 1 页往前翻,翻到「本页最旧成交日 < cutoff」即停,只补最近几天的新增。
|
|
|
|
|
+ cutoff = 今天 - days_back,且判定为严格小于,故 cutoff 当天的数据仍会被收下。
|
|
|
|
|
+ 例:00:01 定时跑、days_back=1 时 cutoff=昨天,翻到「前天」的数据即停 —— 正好收完整的昨天。
|
|
|
|
|
+
|
|
|
|
|
+ 匿名请求下服务端最多放行前 600 条(30 页),故硬上限为 MAX_ANON_PAGE;若翻到上限仍未
|
|
|
|
|
+ 追到 cutoff,说明近 days_back 天新增已售超过 600 条(本站单日成交量约数百条,days_back
|
|
|
|
|
+ 调大很容易超上限),匿名无法继续深翻,记 warning 提示可能遗漏。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: logger 对象。
|
|
|
|
|
+ sql_pool: 数据库连接池对象。
|
|
|
|
|
+ days_back (int, optional): 增量回溯天数,cutoff = 今天 - days_back。Defaults to 1。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 本轮解析并尝试入库的订单条数(含被 ignore 的重复项)。
|
|
|
"""
|
|
"""
|
|
|
- page_size = 20
|
|
|
|
|
|
|
+ cutoff = (datetime.now() - timedelta(days=days_back)).date()
|
|
|
|
|
+ log.info(f"增量采集已售列表,cutoff={cutoff}(成交日早于此日期即停),匿名翻页上限={MAX_ANON_PAGE}")
|
|
|
|
|
+
|
|
|
total_collected = 0
|
|
total_collected = 0
|
|
|
page = 1
|
|
page = 1
|
|
|
- max_pages = 100
|
|
|
|
|
|
|
+ reached_cutoff = False
|
|
|
|
|
|
|
|
- while page <= max_pages:
|
|
|
|
|
- result = get_sold_single_page(log, page)
|
|
|
|
|
|
|
+ while page <= MAX_ANON_PAGE:
|
|
|
|
|
+ orders, total_count, _ = get_sold_single_page(log, page)
|
|
|
|
|
|
|
|
- if result[0] is None:
|
|
|
|
|
- log.error(f"Error fetching page {page}, stopping...")
|
|
|
|
|
|
|
+ if not orders:
|
|
|
|
|
+ log.info(f"Page {page} 无数据,停止")
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
- orders, total_count = result
|
|
|
|
|
- total_collected += len(parse_list_results(log, orders, sql_pool))
|
|
|
|
|
|
|
+ inserted_count = len(parse_list_results(log, orders, sql_pool))
|
|
|
|
|
+ total_collected += inserted_count
|
|
|
|
|
|
|
|
- # 第一页时打印总数
|
|
|
|
|
if page == 1:
|
|
if page == 1:
|
|
|
- total_pages = (total_count + page_size - 1) // page_size
|
|
|
|
|
- log.info(f"Total items: {total_count}, Total pages: {total_pages}")
|
|
|
|
|
|
|
+ log.info(f"Total items(接口累计已售): {total_count}")
|
|
|
|
|
+
|
|
|
|
|
+ oldest = parse_deal_date(orders[-1])
|
|
|
|
|
+ log.info(f"Page {page} processed, fetched={len(orders)}, parsed={inserted_count}, 本页最旧成交日={oldest}")
|
|
|
|
|
|
|
|
- if len(orders) < page_size:
|
|
|
|
|
- log.info(f"Less than {page_size} items on page {page}, stopping...")
|
|
|
|
|
|
|
+ # 已翻到早于 cutoff 的数据,增量部分采集完毕
|
|
|
|
|
+ if oldest and oldest < cutoff:
|
|
|
|
|
+ log.info(f"本页最旧成交日 {oldest} < cutoff {cutoff},增量采集完成,停止")
|
|
|
|
|
+ reached_cutoff = True
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
- # 检查是否还有下一页
|
|
|
|
|
- if page * page_size >= total_count:
|
|
|
|
|
- log.info(f"No more pages, stopping...")
|
|
|
|
|
|
|
+ # 末页(接口返回不足一页)
|
|
|
|
|
+ if len(orders) < PAGE_SIZE:
|
|
|
|
|
+ log.info(f"Page {page} 不足 {PAGE_SIZE} 条,已到列表末尾,停止")
|
|
|
|
|
+ reached_cutoff = True
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
page += 1
|
|
page += 1
|
|
|
|
|
|
|
|
- log.info(f"Total orders collected: {total_collected}")
|
|
|
|
|
|
|
+ if not reached_cutoff:
|
|
|
|
|
+ log.warning(
|
|
|
|
|
+ f"已翻到匿名上限第 {MAX_ANON_PAGE} 页仍未追到 cutoff {cutoff},"
|
|
|
|
|
+ f"说明近 {days_back} 天新增已售 > {MAX_ANON_PAGE * PAGE_SIZE} 条,"
|
|
|
|
|
+ f"未登录无法继续深翻,本轮可能有遗漏——建议提高采集频率或改带登录 token 深翻")
|
|
|
|
|
+
|
|
|
|
|
+ log.info(f"Total orders collected(含重复忽略前): {total_collected}")
|
|
|
return total_collected
|
|
return total_collected
|
|
|
|
|
|
|
|
|
|
|