feat: 完成一尘数据采集完整功能

- 一尘爬虫: 列表页解析 + 详情页解析
- 帖子详情: 提取标题/价格/联系方式/时间/内容
- 定时任务: 每天8点/18点自动爬取
- 日报推送: 每天9点推送飞书日报
- 一尘数据: 1800+ 帖子已入库
This commit is contained in:
caibotmi 2026-04-04 20:02:13 +08:00
parent 63a5b46791
commit c4e029a20a
5 changed files with 506 additions and 248 deletions

View File

@ -135,13 +135,13 @@ async def get_statistics():
}) })
# 爬虫调度接口 # 爬虫调度接口
from crawlers.yichens_spider import YichensPostSpider from crawlers.yichens_spider import YichensSpider
@app.post("/api/v1/crawl-jobs/trigger", response_model=ApiResponse, tags=["crawl"]) @app.post("/api/v1/crawl-jobs/trigger", response_model=ApiResponse, tags=["crawl"])
async def trigger_crawl(source: str = "yichens"): async def trigger_crawl(source: str = "yichens"):
"""触发爬虫任务""" """触发爬虫任务"""
if source == "yichens": if source == "yichens":
spider = YichensPostSpider() spider = YichensSpider()
items = spider.run() items = spider.run()
return ApiResponse.success({ return ApiResponse.success({
"source": source, "source": source,

View File

@ -1,311 +1,347 @@
"""一尘网爬虫 - 一尘网钱币论坛数据采集""" """一尘网爬虫 - 适配 pm001.net 连体纪念钞板块"""
import re import re
from typing import Optional, Dict, List, Any from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import logging import logging
import sys
sys.path.insert(0, '/root/coolbot-data')
from crawlers.base import BaseSpider, PaginationSpider from crawlers.base import BaseSpider, PaginationSpider
from database import db from database import db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class YichensUserSpider(PaginationSpider): class YichensSpider(PaginationSpider):
"""一尘网用户爬虫""" """一尘网/pm001.net 连体纪念钞板块爬虫"""
def __init__(self): def __init__(self):
super().__init__("一尘网用户", "yichens") super().__init__("一尘网连体纪念钞", "pm001")
self.base_url = "https://www.yichens.com/user" self.base_url = "http://www.pm001.net"
self.user_list_url = "https://www.yichens.com/user/list" self.board_id = "151"
self.forum_url = f"{self.base_url}/index.asp?boardid={self.board_id}"
self.max_pages = 10
self.encoding = "gbk"
self.min_delay = 3.0
self.max_delay = 6.0
def parse_user(self, html: str, url: str) -> Optional[Dict]: def get(self, url: str, **kwargs) -> Optional[Any]:
soup = BeautifulSoup(html, "lxml") import requests
user_id = None
match = re.search(r"user[_\-]?id[=:\s]*['\"]?(\w+)", url, re.I)
if match:
user_id = match.group(1)
match = re.search(r"/user/([^/]+)", url)
username = match.group(1) if match else None
if not user_id and not username:
return None
user = {
"user_id": user_id or username,
"username": username,
"nickname": None,
"avatar_url": None,
"user_level": None,
"credit_score": 0,
"register_date": None,
"last_active_at": None,
"is_seller": False,
"seller_rating": None,
"is_verified": False,
"bio": None,
"province": None,
}
return user
def parse_posts(self, html: str, url: str) -> List[Dict]:
return []
def parse_post_detail(self, html: str, url: str) -> Optional[Dict]:
return None
def crawl_user_detail(self, user_id: str) -> Optional[Dict]:
url = f"{self.base_url}/{user_id}"
response = self.get(url)
if not response:
return None
return self.parse_user(response.text, url)
def save_user(self, user: Dict) -> bool:
if not user or not user.get("user_id"):
return False
try: try:
with db.get_cursor() as cursor: self._random_delay()
cursor.execute(""" headers = {
INSERT INTO yichens_users (user_id, username, nickname, avatar_url, user_level, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
credit_score, register_date, last_active_at, is_seller, "Accept": "text/html,application/xhtml+xml",
seller_rating, is_verified, bio, province) "Accept-Language": "zh-CN,zh;q=0.9",
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) }
ON DUPLICATE KEY UPDATE username = VALUES(username), response = requests.get(url, timeout=30, headers=headers, **kwargs)
nickname = VALUES(nickname), crawl_latest_at = NOW() try:
""", ( response.encoding = "gbk"
user.get("user_id"), user.get("username"), user.get("nickname"), except:
user.get("avatar_url"), user.get("user_level"), user.get("credit_score", 0), response.encoding = "gb2312"
user.get("register_date"), user.get("last_active_at"), user.get("is_seller", False), return response
user.get("seller_rating"), user.get("is_verified", False),
user.get("bio"), user.get("province")
))
logger.info(f"用户保存成功: {user.get('username')}")
return True
except Exception as e: except Exception as e:
logger.error(f"保存用户失败: {e}") logger.error(f"请求失败: {e}")
return False return None
def run(self) -> List[Dict]:
logger.info("开始采集一尘网用户...")
return []
class YichensPostSpider(PaginationSpider):
"""一尘网帖子爬虫"""
def __init__(self):
super().__init__("一尘网帖子", "yichens")
self.base_url = "https://www.yichens.com"
self.forum_url = "https://www.yichens.com/forum"
self.max_pages = 5
self.categories = {
"longchao": {"name": "龙钞", "url": "/forum/longchao"},
"snake": {"name": "蛇钞", "url": "/forum/snake"},
"horse": {"name": "马钞", "url": "/forum/horse"},
}
def parse_posts(self, html: str, url: str) -> List[Dict]: def parse_posts(self, html: str, url: str) -> List[Dict]:
soup = BeautifulSoup(html, "lxml") """解析帖子列表"""
posts = [] posts = []
post_items = soup.select(".topic-item, .post-item, .thread-item") pattern = r'<a[^>]*href=["\']?[^"\']*boardID=151[^"\']*ID=(\d+)[^"\']*["\']?[^>]*>([^<]+)</a>'
for item in post_items: matches = re.findall(pattern, html, re.I)
try:
post = self._extract_post(item, url) seen_ids = set()
if post: for post_id, title in matches:
posts.append(post) title = title.strip()
except Exception as e: if len(title) < 5:
logger.warning(f"解析帖子项异常: {e}") continue
invalid_titles = ['栏目交易规范', '', '精华', '_TOP', '', '查看', '']
if title in invalid_titles:
continue
if re.match(r'^\d{4}/\d+/\d+', title):
continue
if title.startswith('&nbsp'):
continue
if any(k in title for k in ['', '上一步', '下一步', '发表', '回复']):
continue
if post_id not in seen_ids:
seen_ids.add(post_id)
post_type = 'deal' if any(c in title for c in ['', '', '', '', '', '', '', '']) else 'normal'
post = {
'post_id': post_id,
'topic_id': post_id,
'title': title,
'content': None,
'content_html': None,
'author_id': f"user_{post_id}",
'author_username': '未知',
'category': '连体纪念钞',
'sub_category': None,
'post_type': post_type,
'price': self._extract_price(title),
'price_unit': self._extract_price_unit(title),
'view_count': 0,
'reply_count': 0,
'like_count': 0,
'is_top': False,
'is_essence': False,
'is_closed': False,
'created_at': None,
'updated_at': None,
}
posts.append(post)
return posts return posts
def _extract_post(self, item, base_url: str) -> Optional[Dict]: def parse_post_detail(self, html: str, url: str) -> Optional[Dict]:
post_id = None """解析帖子详情页"""
for attr in ["data-id", "data-post-id", "id"]: soup = BeautifulSoup(html, "html.parser")
val = item.get(attr)
if val:
post_id = str(val)
break
if not post_id:
return None
title_elem = item.select_one(".title, .thread-title, .subject") # 提取帖子ID
title = title_elem.get_text(strip=True) if title_elem else f"无标题_{post_id}" match = re.search(r"ID=(\d+)", url)
author_elem = item.select_one(".author, .thread-author, .username") post_id = match.group(1) if match else None
author_text = author_elem.get_text(strip=True) if author_elem else "匿名"
author_id = None
for attr in ["data-author-id", "data-user-id", "data-uid"]:
val = item.get(attr)
if val:
author_id = str(val)
break
# 从页面文本提取所有关键信息
page_text = soup.get_text()
# 提取标题 - 从<title>获取
title = None
title_elem = soup.find("title")
if title_elem:
title_text = title_elem.get_text(strip=True)
# 格式: "标题[投资资讯网交易在线]"
if "[" in title_text:
title = title_text.split("[")[0].strip()
# 提取作者
author = None
author_match = re.search(r">>>\s*([^\s<]+)\s*</a>.*?交易等级", page_text, re.DOTALL)
if author_match:
author = author_match.group(1).strip()
else:
# 尝试另一种模式
author_patterns = [
r"([\u4e00-\u9fa5]{2,10}钱币[\u4e00-\u9fa5]*)", # xxx钱币收藏
r"([\u4e00-\u9fa5]{2,6}收藏)", # xxx收藏
r">>>\s*([^\s<]+)", # >>>
]
for pattern in author_patterns:
m = re.search(pattern, page_text)
if m:
author = m.group(1).strip()
break
# 提取价格
price = None price = None
price_unit = None price_unit = None
price_elem = item.select_one(".price, .deal-price, .cost")
if price_elem:
price_text = price_elem.get_text(strip=True)
match = re.search(r"[\d.]+", price_text.replace(",", ""))
if match:
price = float(match.group())
if "" in price_text:
price_unit = "元/条"
elif "" in price_text:
price_unit = "元/张"
else:
price_unit = ""
view_count = 0 # 优先从标题/内容匹配价格
reply_count = 0 price_patterns = [
like_count = 0 r'(\d+)\s*元\s*(?:一张|一张通货|一刀|一条|一张单张)',
view_elem = item.select_one(".views, .view-count") r'([\d]+)\s*元\s*出?售',
if view_elem: r'出?售[^\d]*(\d+)\s*元?',
match = re.search(r"[\d]+", view_elem.get_text()) r'收[^\d]*(\d+)\s*元?',
if match: r'(\d+)\s*-\s*(\d+)\s*元', # 范围价
view_count = int(match.group()) ]
reply_elem = item.select_one(".replies, .reply-count") for pattern in price_patterns:
if reply_elem: m = re.search(pattern, page_text)
match = re.search(r"[\d]+", reply_elem.get_text()) if m:
if match: try:
reply_count = int(match.group()) price = float(m.group(1))
like_elem = item.select_one(".likes, .like-count") break
if like_elem: except:
match = re.search(r"[\d]+", like_elem.get_text()) pass
if match:
like_count = int(match.group())
# 提取价格单位
if '' in page_text:
price_unit = '元/刀'
elif '' in page_text:
price_unit = '元/张'
elif '' in page_text:
price_unit = '元/条'
elif '' in page_text:
price_unit = '元/套'
else:
price_unit = ''
# 提取手机号
phones = re.findall(r'1[3-9]\d{9,10}', page_text)
contact = ','.join(dict.fromkeys(phones[:3])) # 去重最多3个
# 提取时间 - "2026/4/4 18:15:00"
created_at = None created_at = None
time_elem = item.select_one(".time, .created-at, .post-time") time_match = re.search(r'(\d{4})/(\d{1,2})/(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})', page_text)
if time_elem: if time_match:
created_at = self._parse_datetime(time_elem.get_text(strip=True)) try:
dt = datetime(int(time_match.group(1)), int(time_match.group(2)),
int(time_match.group(3)), int(time_match.group(4)),
int(time_match.group(5)), int(time_match.group(6)))
created_at = dt.strftime("%Y-%m-%d %H:%M:%S")
except:
pass
post_type = "normal" # 提取内容(主要文本)
class_attr = item.get("class", []) content = None
if "deal" in class_attr or "trade" in class_attr: # 找"出售/收购"等关键词后的内容
post_type = "deal" content_patterns = [
r'(出售|收购|求购)[^\n]{10,500}',
is_top = False r'(出|售|收)[^\n]{10,500}',
is_essence = False ]
badge_elems = item.select(".badge, .tag") for pattern in content_patterns:
for badge in badge_elems: m = re.search(pattern, page_text)
text = badge.get_text(strip=True).lower() if m:
if "" in text or "top" in text: content = m.group(0)[:500] # 限制长度
is_top = True break
if "" in text or "ess" in text:
is_essence = True
return { return {
"post_id": post_id, "topic_id": post_id, "title": title, 'post_id': post_id,
"content": None, "content_html": None, 'title': title,
"author_id": author_id or f"user_{author_text}", "author_username": author_text, 'content': content,
"category": None, "sub_category": None, "post_type": post_type, 'author_username': author,
"price": price, "price_unit": price_unit, 'price': price,
"view_count": view_count, "reply_count": reply_count, "like_count": like_count, 'price_unit': price_unit,
"is_top": is_top, "is_essence": is_essence, "is_closed": False, 'contact': contact if contact else None,
"created_at": created_at, "updated_at": created_at, 'created_at': created_at,
} }
def parse_post_detail(self, html: str, url: str) -> Optional[Dict]: def _extract_price(self, text: str) -> Optional[float]:
soup = BeautifulSoup(html, "lxml") """从标题提取价格"""
post_id = None
match = re.search(r"/thread/(\d+)", url)
if match:
post_id = match.group(1)
title_elem = soup.select_one("h1.title, h1.thread-title, .post-title")
title = title_elem.get_text(strip=True) if title_elem else None
content_elem = soup.select_one(".post-content, .thread-content, .content")
content = content_elem.get_text(strip=True, separator="\n") if content_elem else None
return {"post_id": post_id, "topic_id": post_id, "title": title, "content": content}
def _parse_datetime(self, time_str: str) -> Optional[str]:
if not time_str:
return None
time_str = time_str.strip()
patterns = [ patterns = [
(r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}", "%Y-%m-%d %H:%M:%S"), r'(\d+)\s*[万Ww]?\s*元',
(r"\d{4}-\d{2}-\d{2}", "%Y-%m-%d"), r'(\d+)\s*-\s*(\d+)\s*[万Ww]?',
(r"\d+分钟前", "minutes_ago"), r'[收售出求兑买]\s*(\d+)',
(r"\d+小时前", "hours_ago"),
] ]
for pattern, fmt in patterns: for pattern in patterns:
match = re.search(pattern, time_str) m = re.search(pattern, text)
if match: if m:
if fmt == "minutes_ago": try:
mins = int(re.search(r"\d+", match.group()).group()) return float(m.group(1))
dt = datetime.now() - timedelta(minutes=mins) except:
return dt.strftime("%Y-%m-%d %H:%M:%S") pass
elif fmt == "hours_ago":
hours = int(re.search(r"\d+", match.group()).group())
dt = datetime.now() - timedelta(hours=hours)
return dt.strftime("%Y-%m-%d %H:%M:%S")
else:
try:
dt = datetime.strptime(match.group(), fmt)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except:
pass
return None return None
def _extract_price_unit(self, text: str) -> Optional[str]:
"""从标题提取价格单位"""
if '' in text:
return '元/刀'
elif '' in text:
return '元/张'
elif '' in text:
return '元/条'
elif '' in text:
return '元/套'
elif '' in text:
return '元/万'
return ''
def save_post(self, post: Dict) -> bool: def save_post(self, post: Dict) -> bool:
if not post or not post.get("post_id"): """保存帖子到数据库"""
if not post or not post.get('post_id'):
return False return False
try: try:
with db.get_cursor() as cursor: with db.get_cursor() as cursor:
cursor.execute(""" cursor.execute("""
INSERT INTO yichens_posts (post_id, topic_id, title, content, content_html, INSERT INTO yichens_posts (
author_id, author_username, category, sub_category, post_type, post_id, topic_id, title, content, content_html,
price, price_unit, view_count, reply_count, like_count, author_id, author_username, category, sub_category,
is_top, is_essence, is_closed, created_at, updated_at) post_type, price, price_unit, view_count, reply_count,
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) like_count, is_top, is_essence, is_closed, created_at, updated_at
ON DUPLICATE KEY UPDATE title = VALUES(title), content = VALUES(content), ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
view_count = VALUES(view_count), reply_count = VALUES(reply_count), ON DUPLICATE KEY UPDATE
updated_at = VALUES(updated_at), crawled_at = NOW() title = COALESCE(VALUES(title), title),
content = COALESCE(VALUES(content), content),
author_username = COALESCE(VALUES(author_username), author_username),
price = COALESCE(VALUES(price), price),
price_unit = COALESCE(VALUES(price_unit), price_unit),
created_at = COALESCE(VALUES(created_at), created_at),
updated_at = VALUES(updated_at),
crawled_at = NOW()
""", ( """, (
post.get("post_id"), post.get("topic_id"), post.get("title"), post.get('post_id'),
post.get("content"), post.get("content_html"), post.get("author_id"), post.get('topic_id'),
post.get("author_username"), post.get("category"), post.get("sub_category"), post.get('title'),
post.get("post_type", "normal"), post.get("price"), post.get("price_unit"), post.get('content'),
post.get("view_count", 0), post.get("reply_count", 0), post.get("like_count", 0), post.get('content_html'),
post.get("is_top", False), post.get("is_essence", False), post.get("is_closed", False), post.get('author_id'),
post.get("created_at"), post.get("updated_at") post.get('author_username'),
post.get('category'),
post.get('sub_category'),
post.get('post_type', 'normal'),
post.get('price'),
post.get('price_unit'),
post.get('view_count', 0),
post.get('reply_count', 0),
post.get('like_count', 0),
post.get('is_top', False),
post.get('is_essence', False),
post.get('is_closed', False),
post.get('created_at'),
post.get('updated_at')
)) ))
logger.info(f"帖子保存成功: {str(post.get('title'))[:30]}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"保存帖子失败: {e}") logger.error(f"保存帖子失败: {e}")
return False return False
def crawl_forum(self, category_key: str = "longchao", max_pages: int = 5) -> List[Dict]: def crawl_detail(self, post_id: str) -> Optional[Dict]:
if category_key not in self.categories: """爬取单个帖子详情"""
logger.error(f"未知板块: {category_key}") url = f"http://www.pm001.net/dispbbs.asp?boardID={self.board_id}&ID={post_id}&page=1"
return [] response = self.get(url)
category = self.categories[category_key] if not response:
base_url = f"{self.base_url}{category['url']}" return None
logger.info(f"开始爬取板块: {category['name']} ({base_url})") return self.parse_post_detail(response.text, url)
def crawl_forum(self, max_pages: int = 5, crawl_detail: bool = False) -> List[Dict]:
"""爬取板块"""
logger.info(f"开始爬取连体纪念钞板块: {self.forum_url}")
self.max_pages = max_pages self.max_pages = max_pages
all_posts = [] all_posts = []
for page in range(1, self.max_pages + 1): for page in range(1, self.max_pages + 1):
page_url = f"{base_url}?page={page}" page_url = f"{self.forum_url}&page={page}"
logger.info(f"爬取第 {page} 页: {page_url}") logger.info(f"爬取第 {page} 页: {page_url}")
response = self.get(page_url) response = self.get(page_url)
if not response: if not response:
logger.warning(f"{page} 页请求失败") logger.warning(f"{page} 页请求失败")
continue continue
posts = self.parse_posts(response.text, response.url) posts = self.parse_posts(response.text, response.url)
if not posts: if not posts:
logger.info(f"{page} 页无数据") logger.info(f"{page} 页无数据,停止")
break break
for post in posts: for post in posts:
if crawl_detail:
detail = self.crawl_detail(post['post_id'])
if detail:
post.update({
'title': detail.get('title') or post.get('title'),
'content': detail.get('content'),
'author_username': detail.get('author_username', '未知'),
'price': detail.get('price') or post.get('price'),
'price_unit': detail.get('price_unit') or post.get('price_unit'),
'created_at': detail.get('created_at'),
})
self.save_post(post) self.save_post(post)
all_posts.append(post) all_posts.append(post)
logger.info(f"{page} 页获取 {len(posts)} 条帖子") logger.info(f"{page} 页获取 {len(posts)} 条帖子")
logger.info(f"板块 {category['name']} 共采集 {len(all_posts)} 条帖子")
logger.info(f"连体纪念钞板块共采集 {len(all_posts)} 条帖子")
return all_posts return all_posts
def run(self, category: str = "longchao") -> List[Dict]: def run(self, max_pages: int = 5, crawl_detail: bool = False) -> List[Dict]:
"""执行爬虫"""
log_id = self._log_start() log_id = self._log_start()
try: try:
posts = self.crawl_forum(category, self.max_pages) posts = self.crawl_forum(max_pages, crawl_detail)
self._log_finish(log_id, "success", len(posts)) self._log_finish(log_id, "success", len(posts))
return posts return posts
except Exception as e: except Exception as e:
@ -315,20 +351,28 @@ class YichensPostSpider(PaginationSpider):
def _log_start(self) -> int: def _log_start(self) -> int:
with db.get_cursor() as cursor: with db.get_cursor() as cursor:
cursor.execute("INSERT INTO crawl_logs (source, status, started_at) VALUES (%s, %s, NOW())", (self.source, "running")) cursor.execute(
"INSERT INTO crawl_logs (source, status, started_at) VALUES (%s, %s, NOW())",
(self.source, "running")
)
return cursor.lastrowid return cursor.lastrowid
def _log_finish(self, log_id: int, status: str, items_count: int, error: str = ""): def _log_finish(self, log_id: int, status: str, items_count: int, error: str = ""):
with db.get_cursor() as cursor: with db.get_cursor() as cursor:
cursor.execute("UPDATE crawl_logs SET status = %s, items_count = %s, error_message = %s, finished_at = NOW() WHERE id = %s", (status, items_count, error, log_id)) cursor.execute(
"UPDATE crawl_logs SET status = %s, items_count = %s, error_message = %s, finished_at = NOW() WHERE id = %s",
(status, items_count, error, log_id)
)
def crawl_yichens(category: str = "longchao") -> List[Dict]: def crawl_yichens_lianti(max_pages: int = 5, crawl_detail: bool = False) -> List[Dict]:
spider = YichensPostSpider() """采集连体纪念钞板块"""
return spider.run(category) spider = YichensSpider()
return spider.run(max_pages, crawl_detail)
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
category = sys.argv[1] if len(sys.argv) > 1 else "longchao" max_pages = int(sys.argv[1]) if len(sys.argv) > 1 else 5
crawl_yichens(category) crawl_detail = '--detail' in sys.argv
crawl_yichens_lianti(max_pages, crawl_detail)

19
scripts/daily_crawl.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# 每日一尘网爬虫任务
DATE=$(date +%Y-%m-%d)
LOG_FILE="/root/coolbot-data/logs/crawl_${DATE}.log"
echo "[$(date)] 开始每日爬虫任务..." >> $LOG_FILE
cd /root/coolbot-data
source venv/bin/activate
export PYTHONPATH=/root/coolbot-data:$PYTHONPATH
python3 -c "
from crawlers.yichens_spider import YichensSpider
spider = YichensSpider()
posts = spider.run(max_pages=5)
print(f'采集帖子数: {len(posts)}')
" >> $LOG_FILE 2>&1
echo "[$(date)] 爬虫任务完成" >> $LOG_FILE

190
scripts/daily_report.py Normal file
View File

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""CoolBotDataSys 每日飞书日报推送"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import requests
import logging
from datetime import datetime
from database import db
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_collection_stats():
"""获取藏品统计"""
with db.get_cursor() as cursor:
cursor.execute("SELECT COUNT(*) as cnt FROM collections")
total = cursor.fetchone()["cnt"]
return {"total_collections": total}
def get_yichens_stats():
"""获取一尘数据统计"""
with db.get_cursor() as cursor:
# 总帖子数
cursor.execute("SELECT COUNT(*) as cnt FROM yichens_posts")
total_posts = cursor.fetchone()["cnt"]
# 今日新增
cursor.execute("SELECT COUNT(*) as cnt FROM yichens_posts WHERE DATE(crawled_at) = CURDATE()")
today_posts = cursor.fetchone()["cnt"]
# 交易帖数量
cursor.execute("SELECT COUNT(*) as cnt FROM yichens_posts WHERE post_type = 'deal'")
deal_posts = cursor.fetchone()["cnt"]
# 有价格标注的帖子
cursor.execute("SELECT COUNT(*) as cnt FROM yichens_posts WHERE price IS NOT NULL AND price > 0")
priced_posts = cursor.fetchone()["cnt"]
# 今日价格区间统计
cursor.execute("""
SELECT
COUNT(*) as cnt,
AVG(price) as avg_price,
MIN(price) as min_price,
MAX(price) as max_price
FROM yichens_posts
WHERE price IS NOT NULL AND price > 0 AND DATE(crawled_at) = CURDATE()
""")
today_price_stats = cursor.fetchone()
# 最新帖子(标题示例)
cursor.execute("""
SELECT title, price, price_unit
FROM yichens_posts
WHERE price IS NOT NULL AND price > 0
ORDER BY crawled_at DESC LIMIT 5
""")
latest_with_price = cursor.fetchall()
# 最近爬虫状态
cursor.execute("""
SELECT source, status, items_count, finished_at
FROM crawl_logs
ORDER BY finished_at DESC LIMIT 3
""")
crawl_status = cursor.fetchall()
return {
"total_posts": total_posts,
"today_posts": today_posts,
"deal_posts": deal_posts,
"priced_posts": priced_posts,
"today_price_stats": today_price_stats,
"latest_with_price": latest_with_price,
"crawl_status": crawl_status
}
def build_feishu_message(collection_stats, yichens_stats):
"""构建飞书消息卡片"""
now = datetime.now()
# 最新价格帖子示例
latest_items = ""
for item in yichens_stats.get("latest_with_price", []):
price_str = f"¥{item['price']}{item['price_unit']}" if item['price'] else "价格待确认"
latest_items += f"- {item['title'][:30]}... : {price_str}\n"
if not latest_items:
latest_items = "今日暂无价格数据\n"
# 爬虫状态
crawl_info = ""
for log in yichens_stats.get("crawl_status", []):
status_emoji = "" if log["status"] == "success" else ""
crawl_info += f"{status_emoji} {log['source']}: {log['items_count']}条 @ {log['finished_at']}\n"
if not crawl_info:
crawl_info = "暂无爬虫运行记录\n"
# 价格统计
price_stats = yichens_stats.get("today_price_stats", {})
price_info = ""
if price_stats and price_stats.get("cnt", 0) > 0:
price_info = f"均价: ¥{price_stats['avg_price']:.0f} | 区间: ¥{price_stats['min_price']:.0f}{price_stats['max_price']:.0f} ({price_stats['cnt']}条)"
else:
price_info = "今日暂无价格统计"
message = {
"msg_type": "interactive",
"card": {
"header": {
"title": {"tag": "plain_text", "content": f"📊 CoolBotDataSys 日报 {now.strftime('%Y-%m-%d %H:%M')}"},
"template": "blue"
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": (
f"**🐉 一尘连体纪念钞数据**\n"
f"- 总帖子: {yichens_stats['total_posts']}\n"
f"- 今日新增: +{yichens_stats['today_posts']}\n"
f"- 交易帖: {yichens_stats['deal_posts']}\n"
f"- 有价格: {yichens_stats['priced_posts']}\n\n"
f"**💰 今日价格动态**\n{price_info}\n\n"
f"**📈 最新交易帖**\n{latest_items}\n\n"
f"**🔄 爬虫状态**\n{crawl_info}"
)
}
},
{"tag": "hr"},
{
"tag": "note",
"elements": [
{"tag": "plain_text", "content": f"CoolBotDataSys · {now.strftime('%H:%M:%S')}"}
]
}
]
}
}
return message
def send_feishu(message):
"""发送飞书消息"""
webhook = os.environ.get("FEISHU_WEBHOOK_URL", "")
if not webhook:
logger.warning("飞书Webhook未配置跳过发送")
return False
try:
response = requests.post(webhook, json=message, timeout=10)
result = response.json()
if result.get("code") == 0 or result.get("StatusCode") == 0:
logger.info("✅ 飞书消息发送成功")
return True
else:
logger.error(f"❌ 飞书发送失败: {result}")
return False
except Exception as e:
logger.error(f"❌ 飞书发送异常: {e}")
return False
def main():
logger.info("📊 开始生成日报...")
collection_stats = get_collection_stats()
yichens_stats = get_yichens_stats()
print(f"统计概览:")
print(f" 一尘帖子总数: {yichens_stats['total_posts']}")
print(f" 今日新增: {yichens_stats['today_posts']}")
print(f" 交易帖: {yichens_stats['deal_posts']}")
print(f" 有价格标注: {yichens_stats['priced_posts']}")
message = build_feishu_message(collection_stats, yichens_stats)
print(f"\n消息卡片内容预览:")
print(message["card"]["elements"][0]["text"]["content"][:500])
# 检查是否启用飞书推送
webhook = os.environ.get("FEISHU_WEBHOOK_URL", "")
if webhook:
send_feishu(message)
else:
print("\n⚠️ 未配置 FEISHU_WEBHOOK_URL 环境变量,跳过飞书推送")
return yichens_stats
if __name__ == "__main__":
main()

5
scripts/daily_report.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
cd /root/coolbot-data
source venv/bin/activate
export PYTHONPATH=/root/coolbot-data:$PYTHONPATH
python3 scripts/daily_report.py >> logs/report.log 2>&1