fix: resolve 10 code review issues

High priority:
- Fix concurrent race condition for view_count/like_count (atomic update)
- Add route request ID tracking to prevent race conditions
- Filter get_joke by status=approved (no pending content leak)
- Add error feedback for like button

Performance:
- Optimize random joke query (avoid full table sort)
- Limit page_size max to 100 (DoS prevention)

Medium:
- Add localStorage quota error handling
- Handle empty AI response gracefully
- Fix generate content title extraction

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
This commit is contained in:
bwstudio
2026-06-02 20:35:08 +08:00
parent 0b43973236
commit ceed63fcb0
144 changed files with 191660 additions and 270 deletions
+439
View File
@@ -0,0 +1,439 @@
"""统一网页获取:搜索笑话站点 + 深度翻页抓取。"""
import asyncio
import os
import re
import sys
from urllib.parse import quote, urljoin, urlparse
# Windows 下禁用 rich 控制台输出,避免 GBK 编码错误
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
os.environ["TERM"] = "dumb"
try:
from crawl4ai import AsyncWebCrawler
from crawl4ai.async_configs import CrawlerRunConfig, BrowserConfig
HAS_CRAWL4AI = True
except ImportError:
HAS_CRAWL4AI = False
print("[!] crawl4ai 未安装,将使用 requests 替代(不支持 JS 渲染)")
import httpx
class CrawlerService:
def __init__(self, headless: bool = True):
self.headless = headless
self.session = None
self._crawler = None
# 站点级别容错跟踪
self.site_failures: dict[str, int] = {}
# 最近一次抓取的原始 HTML(供翻页链接发现使用)
self._last_raw_html: str | None = None
def _get_session(self) -> httpx.Client:
if self.session is None:
self.session = httpx.Client(
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.baidu.com/",
},
timeout=30,
follow_redirects=True,
)
return self.session
async def _get_crawler(self):
"""获取或创建 crawl4ai 实例"""
if HAS_CRAWL4AI and self._crawler is None:
browser_cfg = BrowserConfig(headless=self.headless, verbose=False)
self._crawler = AsyncWebCrawler(config=browser_cfg)
await self._crawler.__aenter__()
if not self.headless:
print(" [*] 浏览器窗口已打开(crawl4ai 控制)")
return self._crawler
async def close(self):
if self._crawler:
await self._crawler.__aexit__(None, None, None)
self._crawler = None
if self.session:
self.session.close()
self.session = None
# ============================================================
# 页面抓取(带重试)
# ============================================================
async def crawl_page_with_retry(self, url: str, max_retries: int = 2, timeout: int = 30000) -> tuple[str, bool]:
"""抓取页面,返回 (html内容, 是否成功),失败自动重试"""
for attempt in range(max_retries + 1):
print(f" [~] 正在获取页面... (尝试 {attempt+1}/{max_retries+1})")
try:
html = await self._crawl_one(url, timeout)
if html:
print(f" [~] 页面获取成功,内容长度: {len(html)} 字符")
return html, True
print(f" [!] 页面内容为空")
except Exception as e:
print(f" [!] 抓取失败 (尝试 {attempt+1}/{max_retries+1}): {url[:60]} - {e}")
if attempt < max_retries:
wait = 3 * (attempt + 1)
print(f" [~] 等待 {wait} 秒后重试...")
await asyncio.sleep(wait)
return "", False
async def _crawl_one(self, url: str, timeout: int) -> str:
"""单次页面抓取,返回纯文本内容(同时保存原始 HTML 供翻页发现)"""
if HAS_CRAWL4AI:
# crawl4ai 统一处理 headless / visible 两种模式
crawler = await self._get_crawler()
result = await crawler.arun(url, config=CrawlerRunConfig(verbose=False))
if result and result.success:
# fit_html 是 AI 清洗后的正文(翻页链接常被清洗掉)
# cleaned_html 保留完整结构(用于翻页发现)
self._last_raw_html = result.cleaned_html or result.html or ""
html = result.fit_html or result.cleaned_html or ""
return self._html_to_text(html)
return ""
else:
# 无 crawl4ai 时 fallback 到 requests
resp = self._get_session().get(url, timeout=timeout / 1000)
if resp.status_code == 200:
self._last_raw_html = resp.text
return self._html_to_text(resp.text)
return ""
@staticmethod
def _html_to_text(html: str) -> str:
"""简易 HTML 转纯文本"""
# 移除 script/style 标签内容
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
# 移除 HTML 标签
text = re.sub(r'<[^>]+>', ' ', text)
# 合并空白
text = re.sub(r'\s+', ' ', text).strip()
return text
# ============================================================
# 搜索笑话站点
# ============================================================
# 已知的笑话聚合站域名黑/白名单
JOKE_SITE_KEYWORDS = [
"笑话大全", "冷笑话", "搞笑段子", "笑话集锦",
"幽默笑话", "爆笑笑话", "成人笑话", "小笑话",
]
async def search_joke_sites(self, keywords: list[str], max_results: int = 10) -> list[dict]:
"""搜索笑话站点,返回 [{url, title, domain}]"""
all_results = []
for keyword in keywords:
results = await self._search_and_filter(keyword, max_results=max_results)
all_results.extend(results)
# 去重(按域名)
seen_domains = set()
unique = []
for r in all_results:
domain = r["domain"]
if domain not in seen_domains:
seen_domains.add(domain)
unique.append(r)
print(f" [*] 搜索到 {len(unique)} 个唯一站点")
for s in unique:
print(f" - {s['domain']}: {s['title'][:40]}")
return unique[:max_results]
async def _search_and_filter(self, keyword: str, max_results: int = 10) -> list[dict]:
"""搜索并过滤出疑似笑话聚合站的结果"""
# 用多个搜索词提高覆盖率
search_queries = [
f"{keyword} 网站",
f"{keyword} 大全",
f"{keyword} 列表",
]
seen = set()
sites = []
for q in search_queries:
if len(sites) >= max_results:
break
search_results = await self._search_bing(q, max_pages=2)
for r in search_results:
if len(sites) >= max_results:
break
url = r["url"]
domain = urlparse(url).netloc.lower()
if domain in seen:
continue
seen.add(domain)
# 过滤:排除已知的单篇文章站点和搜索引擎
if self._is_joke_collection_site(url, r.get("title", "")):
r["domain"] = domain
sites.append(r)
return sites
def _is_joke_collection_site(self, url: str, title: str) -> bool:
"""判断URL是否疑似笑话聚合站(不是单篇文章)"""
domain = urlparse(url).netloc.lower()
path = urlparse(url).path.lower()
# 排除项
exclude_domains = [
"bing.com", "microsoft.com", "baidu.com", "google.com",
"sohu.com", "sina.com", "163.com", "qq.com", "toutiao.com",
"weibo.com", "zhihu.com", "bilibili.com", "douban.com",
]
if any(d in domain for d in exclude_domains):
return False
# 排除明显的单篇文章模式
single_article_patterns = [
r'/p/\d+', r'/article/\d+', r'/post/\d+', r'/archives/\d+',
r'/a/\d+', r'/\d{5,}', r'/detail/\d+', r'/read/\d+',
r'\.html$', # 静态 html 文章页
]
# 但如果域名本身含 joke 特征,不排除
is_joke_domain = any(kw in domain or kw in title for kw in
["joke", "xiaohua", "笑话", "段子", "幽默", "搞笑"])
for p in single_article_patterns:
if re.search(p, path) and not is_joke_domain:
return False
# 聚合站特征:域名或标题含特定词,或URL有分类/列表模式
collection_patterns = [
"joke", "xiaohua", "笑话", "段子", "幽默", "搞笑",
"/page/", "/list/", "/category/", "/tag/", "joke",
]
for p in collection_patterns:
if p in domain or p in path or p.lower() in title:
return True
# 有列表/目录模式的也认为是聚合站
if re.search(r'(page|list|category|tag|index)', path):
return True
return False
# ============================================================
# 翻页链接发现
# ============================================================
def discover_page_links(self, html: str, base_url: str) -> list[str]:
"""从页面 HTML 中发现翻页链接,返回去重排序后的 URL 列表"""
# 优先使用 _last_raw_html(完整 HTML 而非纯文本),兜底用传入的 html
raw = self._last_raw_html or html
base_parsed = urlparse(base_url)
base_domain = f"{base_parsed.scheme}://{base_parsed.netloc}"
links = set()
# 1. <link rel="next">
for m in re.finditer(r'<link[^>]*rel="next"[^>]*href="([^"]+)"', raw, re.IGNORECASE):
links.add(urljoin(base_domain, m.group(1)))
# 2. 翻页文字链接(下一页、下页、>、» 等)
page_text_patterns = [
r'<a[^>]*href="([^"]*page[^"]*)"[^>]*>\s*(?:下一页|下页|下一页»|»||>|Next|last)\s*</a>',
r'<a[^>]*>\s*(?:下一页|下页|»||>)\s*</a>\s*<a[^>]*href="([^"]*)"',
]
for pattern in page_text_patterns:
for m in re.finditer(pattern, raw, re.IGNORECASE):
href = m.group(1).strip()
if href and href not in ("#", "javascript:void(0)"):
links.add(urljoin(base_url, href))
# 3. 提取所有带数字的翻页 link?page=N, /page/N/, index_N.html, page_N.html, &page=N
page_link_patterns = [
r'href="([^"]*[?&]page=(\d+)[^"]*)"', # ?page=2 &page=2
r'href="([^"]*/page/(\d+)[^"]*)"', # /page/2/
r'href="([^"]*[?&]p=(\d+)[^"]*)"', # ?p=2
r'href="([^"]*[?&]pn=(\d+)[^"]*)"', # ?pn=2
r'href="([^"]*[?&]offset=(\d+)[^"]*)"', # ?offset=10
r'href="([^"]*[?&]start=(\d+)[^"]*)"', # ?start=10
r'href="([^"]*[?&]page_index=(\d+)[^"]*)"', # ?page_index=2
# 匹配 xxx_N.html / page_N.html / list_2.html
r'href="([^"]*(?:page|list|index)[-_]?(\d+)\.html?)"',
# 匹配 /page_N/ 格式
r'href="([^"]*/page[-_]?(\d+)/?)"',
]
for pattern in page_link_patterns:
for m in re.finditer(pattern, raw, re.IGNORECASE):
full_url = urljoin(base_url, m.group(1))
links.add(full_url)
# 4. 翻页数字链接
for m in re.finditer(r'<a[^>]*href="([^"]*page=(\d+)[^"]*)"[^>]*>\s*\d+\s*</a>', raw, re.IGNORECASE):
links.add(urljoin(base_url, m.group(1)))
# 过滤:只保留同一域名下的链接
result = []
for link in links:
parsed = urlparse(link)
if parsed.netloc and parsed.netloc != base_parsed.netloc:
continue # 跨域排除
if parsed.path == base_parsed.path and parsed.query == base_parsed.query:
continue # 排除自身
result.append(link)
# 去重排序
return sorted(set(result))
def _extract_page_number(self, url: str) -> int:
"""从 URL 中提取页码,用于排序"""
nums = re.findall(r'page[=/](\d+)|[?&]p=(\d+)|index[-_]?(\d+)|/page[-_]?(\d+)', url, re.IGNORECASE)
for n in nums:
for g in n:
if g:
return int(g)
return 99 # 没识别到页码的排最后
# ============================================================
# 分类/标签链接发现
# ============================================================
def discover_category_links(self, html: str, base_url: str) -> list[str]:
"""从页面 HTML 中发现分类/标签链接,返回去重排序后的 URL 列表"""
raw = self._last_raw_html or html
base_parsed = urlparse(base_url)
base_domain = f"{base_parsed.scheme}://{base_parsed.netloc}"
links = set()
# 1. 匹配分类链接(category-N.html, category-N_M.html, tag-N.html 等)
cat_patterns = [
r'href="([^"]*/(?:category|cat|sort|type)[-_]?\d+(?:[-_]\d+)?\.html?)"',
r'href="([^"]*/(?:tag|tags)/?[-_]?\d*)["\s>]',
r'href="([^"]*/tags?[-_]?\d+\.html?)"',
]
for pattern in cat_patterns:
for m in re.finditer(pattern, raw, re.IGNORECASE):
full_url = urljoin(base_domain, m.group(1))
parsed = urlparse(full_url)
# 只保留同域链接
if parsed.netloc and parsed.netloc != base_parsed.netloc:
continue
links.add(full_url)
# 2. 过滤:排除单篇文章、首页、搜索页
single_article = re.compile(
r'/(?:p|post|article|archives|detail|read|xiaohua)/\d+',
re.IGNORECASE,
)
result = []
for link in links:
parsed = urlparse(link)
path = parsed.path.rstrip("/")
# 排除自身
if path == base_parsed.path.rstrip("/") and parsed.query == base_parsed.query:
continue
# 排除单篇文章
if single_article.search(path):
continue
# 排除明显的非分类路径(首页翻页)
if re.search(r'/page[-_]?\d+\.html?$', path) and 'category' not in path and 'tag' not in path:
continue
result.append(link)
return sorted(set(result))
# ============================================================
# Bing 搜索(复用旧逻辑)
# ============================================================
async def _search_bing(self, keyword: str, max_pages: int = 3) -> list[dict]:
"""搜索 Bing,返回结果 URL 列表"""
results = []
session = self._get_session()
for page in range(max_pages):
first = page * 10
url = f"https://www.bing.com/search?q={quote(keyword)}&first={first}"
print(f" [*] Bing 搜索: {keyword[:20]}")
html = None
try:
if HAS_CRAWL4AI:
crawler = await self._get_crawler()
result = await crawler.arun(url, config=CrawlerRunConfig(verbose=False))
if result.success:
html = result.html if result.html else result.cleaned_html
else:
resp = session.get(url)
html = resp.text if resp.status_code == 200 else None
if html:
urls = self._extract_bing_urls(html)
for item in urls:
item["keyword"] = keyword
results.append(item)
else:
print(f" [!] 获取搜索页面失败")
await asyncio.sleep(2)
except Exception as e:
print(f" [!] 搜索异常: {e}")
continue
return results
def _extract_bing_urls(self, html: str) -> list[dict]:
"""从 Bing 搜索结果 HTML 中提取链接和标题"""
results = []
pattern = re.compile(
r'<h2[^>]*>\s*<a[^>]*href="(https?[^"]+)"[^>]*>(.*?)</a>',
re.DOTALL,
)
for match in pattern.finditer(html):
href = match.group(1).strip()
title = re.sub(r'<[^>]+>', '', match.group(2)).strip()
if href and title and len(title) > 5 and "bing.com" not in href and "microsoft.com" not in href:
results.append({"url": href, "title": title})
return results
# 别名兼容
async def search_bing(self, keyword: str, max_pages: int = 3) -> list[dict]:
return await self._search_bing(keyword, max_pages)
async def search(self, keyword: str, max_pages: int = 3) -> list[dict]:
return await self._search_bing(keyword, max_pages)
async def search_baidu(self, keyword: str, max_pages: int = 3) -> list[dict]:
return await self._search_bing(keyword, max_pages)
# ============================================================
# 旧接口兼容(单页抓取)
# ============================================================
async def crawl_page(self, url: str) -> str:
"""抓取单个页面(兼容旧接口)"""
content, ok = await self.crawl_page_with_retry(url)
if ok:
return content[:8000]
return ""
async def crawl_batch(self, urls: list[str]) -> list[tuple[str, str]]:
"""批量抓取(兼容旧接口)"""
contents = []
for url in urls:
content, ok = await self.crawl_page_with_retry(url)
contents.append((url, content[:8000] if ok else ""))
await asyncio.sleep(1.5)
return contents