소스 검색

feat(core): 补采 status='Open' 拍卖记录的最终结果

- 添加 refresh_lot_result 函数重访详情页,检测拍卖是否结束并写回最终数据
- 解析成交状态、出价次数和成交价格,支持 Sold、Unsold 和进行中判断
- 引入重试机制处理残缺页面,确保数据准确更新
- 新增 update_open_lots 函数遍历数据库中状态为 Open 的拍卖记录进行补采
- 在 meml_spdier.py 中调用 update_open_lots,实现拍卖结果的自动补采
- 调整当前拍卖价格提取逻辑,统一处理 “CURRENT BID $” 和 “SOLD FOR $” 两种格式
charley 4 일 전
부모
커밋
f301a188ce
2개의 변경된 파일105개의 추가작업 그리고 2개의 파일을 삭제
  1. 96 1
      memory_lane_spider/meml_core.py
  2. 9 1
      memory_lane_spider/meml_spdier.py

+ 96 - 1
memory_lane_spider/meml_core.py

@@ -8,6 +8,7 @@ Memory Lane 公用模块:HTTP 配置、ASP.NET postback 切换 auction、单
 
 目标网站: https://bid.memorylaneinc.com/lots/gallery
 """
+import re
 import random
 from curl_cffi import requests
 import user_agent
@@ -196,7 +197,7 @@ def get_single_page(log, page, sql_pool, session, impersonate,
 
         status = tag_div_p[2] if len(tag_div_p) > 2 else None
         current_bid = tag_div.xpath('.//div[@class="item-price"]/a/text()').get()
-        current_bid = current_bid.replace('CURRENT BID $', '').replace(',', '').strip() if current_bid else None
+        current_bid = current_bid.replace('CURRENT BID $', '').replace('SOLD FOR $','').replace(',', '').strip() if current_bid else None
 
         data_dict = {
             "title": title,
@@ -296,3 +297,97 @@ def update_details_for_pending(log, sql_pool):
                 data={"state": 2},
                 condition={"id": sql_id}
             )
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
+def refresh_lot_result(log, url, sql_pool, sql_id):
+    """重访详情页,判断拍卖是否结束;结束则回写最终 bids/status/current_bid。
+
+    首采时 status='Open' 表示拍卖进行中、价格未定,抓到的只是当时价格。
+    拍卖结束后详情页会固定出现「This lot is closed. Bidding is not allowed.」,
+    此时才把最终的出价次数、成交状态、成交价写回库。
+
+    Args:
+        log: 日志对象。
+        url (str): 详情页 URL(形如 .../bids/bidplace.aspx?itemid=xxx),来自 memory_lane_record.detail_url。
+        sql_pool: mysql 连接池。
+        sql_id (int): 该记录在 memory_lane_record 的自增 id。
+
+    Returns:
+        str: 'closed' 表示已结束并完成更新;'open' 表示仍在进行、跳过未改动。
+
+    Raises:
+        RuntimeError: 页面残缺(既无结束标志也无价格框)时抛出以触发重试,避免残页被误判为进行中。
+    """
+    log.info(f">>>>>>>>>>>>>> 补采拍卖结果 id={sql_id} URL={url} <<<<<<<<<<<<<<")
+    response = requests.get(url, headers=headers,
+                            impersonate=random.choice(client_identifier_list),
+                            timeout=10, proxies=get_proxys(log))
+    response.raise_for_status()
+    html = response.text
+    selector = Selector(html)
+
+    # 结束信号:拍卖结束后详情页固定出现这句话,进行中的 lot 不会有(最稳的判定依据)
+    closed = "This lot is closed. Bidding is not allowed." in html
+    # 价格框:成交显示「SOLD FOR $x」,流拍显示「UNSOLD」,进行中显示「CURRENT BID $x」
+    price_box = ' '.join(selector.xpath('//h3[@id="MainContent_currentBidBox"]//text()').getall())
+    price_box = re.sub(r'\s+', ' ', price_box).strip()
+
+    if not closed:
+        # 无结束标志且连价格框都没有 → 判定为残页/被限流,抛错重试;否则确系进行中,跳过
+        if not price_box:
+            raise RuntimeError("详情页残缺:既无结束标志也无价格框")
+        log.info(f"id={sql_id} 拍卖仍在进行,跳过(price_box={price_box!r})")
+        return "open"
+
+    # 已结束:解析最终出价次数(Bid History 链接前的 <strong> 即出价数)
+    bids = selector.xpath('//a[@id="MainContent_btnBidHistory"]/preceding-sibling::strong/text()').get()
+    bids = bids.strip() if bids else None
+
+    data = {"bids": bids}
+    price_upper = price_box.upper()
+    if "UNSOLD" in price_upper:
+        # 流拍:详情页不给最终价,只更新 status 与 bids,current_bid 保持原值不动
+        data["status"] = "Unsold"
+    elif "SOLD FOR" in price_upper:
+        # 成交:提取金额并去掉 $ 和千分位逗号,与列表页 current_bid 存法一致(含买家佣金的最终成交价)
+        m = re.search(r'\$([\d,]+(?:\.\d+)?)', price_box)
+        data["status"] = "Sold"
+        data["current_bid"] = m.group(1).replace(',', '') if m else None
+    else:
+        # 未知价格框形态:只记日志、不动 status,避免把不确定的结果误写库
+        log.warning(f"id={sql_id} 价格框形态未知,仅更新 bids: {price_box!r}")
+
+    sql_pool.update_one_or_dict(table="memory_lane_record", data=data, condition={"id": sql_id})
+    log.info(f"id={sql_id} 拍卖已结束,更新 -> {data}")
+    return "closed"
+
+
+def update_open_lots(log, sql_pool):
+    """扫描库里 status='Open' 的记录,补采其最终拍卖结果。
+
+    首采发生在拍卖进行中,只抓到当时价格;本函数逐条重访详情页,
+    对已结束的 lot 回写最终 bids/status/current_bid,未结束的保持不动。
+    更新后 status 变为 Sold/Unsold,下一轮不再命中,随任务多轮运行自然收敛。
+
+    Args:
+        log: 日志对象。
+        sql_pool: mysql 连接池。
+    """
+    log.debug('Refreshing open lots result ...........................')
+    rows = sql_pool.select_all(
+        "select id, detail_url from memory_lane_record where status='Open' order by id")
+    total = len(rows) if rows else 0
+    log.info(f"待补采 status='Open' 记录数: {total}")
+
+    closed_cnt = 0
+    for row in rows or []:
+        sql_id, detail_url = row[0], row[1]
+        try:
+            if refresh_lot_result(log, detail_url, sql_pool, sql_id) == "closed":
+                closed_cnt += 1
+        except Exception as e:
+            # 重试耗尽仍失败:记录并跳过,保持 status='Open' 留待下一轮,不阻塞整体
+            log.error(f"补采拍卖结果失败 id={sql_id} url={detail_url}: {e}")
+            continue
+    log.info(f"本轮补采完成:共 {total} 条,已结束并更新 {closed_cnt} 条")

+ 9 - 1
memory_lane_spider/meml_spdier.py

@@ -11,6 +11,7 @@ Memory Lane 增量爬虫(日调度)
   4. 没有新增 → 本轮无数据可抓,结束
   5. 对每个新增 auction:postback 切换 → 翻页 → 写库
   6. 补抓 state != 1 的详情页
+  7. 补采 status='Open' 的 lot:拍卖结束后回写最终 bids/status/current_bid
 """
 import time
 import random
@@ -26,6 +27,7 @@ from meml_core import (
     crawl_one_auction,
     get_auction_list,
     update_details_for_pending,
+    update_open_lots,
     after_log,
 )
 
@@ -101,6 +103,12 @@ def meml_main(log):
         except Exception as e:
             log.error(f'详情补抓失败: {e}')
 
+        try:
+            # 补采:重访 status='Open' 的 lot,拍卖结束后回写最终 bids/status/current_bid
+            update_open_lots(log, sql_pool)
+        except Exception as e:
+            log.error(f'拍卖结果补采失败: {e}')
+
     except Exception as e:
         log.error(f'{inspect.currentframe().f_code.co_name} error: {e}')
     finally:
@@ -109,7 +117,7 @@ def meml_main(log):
 
 def schedule_task():
     """每半个月跑一次增量"""
-    meml_main(log=logger)
+    # meml_main(log=logger)
 
     def run_semimonthly():
         # 每月 1 号和 15 号执行(半月一次)