如何免费实现自己的原题机,在线和离线。

· · 科技·工程

0.Foreword

最近想搞点 AI 的项目,但是又没想到什么对 OI 有太大帮助的,便思考实现一个原题机。

1.Problems

首先着手于相对方便的洛谷,每次查询访问所有题目是不现实的,需要把所有题目先爬下来。\ 我这里爬了 P1001-P9999 约九千题作为初步测试。

这里是一个爬取洛谷题目的实现:

class BulkLuoguCrawler:
    def __init__(self):
        self.base_url = "https://www.luogu.com.cn"
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        }

    def crawl_problem_range(self, start=1, end=1000):
        """爬取指定范围的题目"""
        problems = []

        for problem_num in range(start, end + 1):
            problem_id = f"P{problem_num}"
            print(f"🔍 爬取题目: {problem_id} ({problem_num}/{end})")

            problem_data = self.get_problem_data(problem_id)
            if problem_data:
                problems.append(problem_data)


        return problems

    def get_problem_data(self, problem_id):
        """获取题目数据"""
        url = f"{self.base_url}/problem/{problem_id}"
        try:
            response = requests.get(url, headers=self.headers, timeout=10)
            if response.status_code == 200:
                return self.parse_problem_page(response.text, problem_id)
            elif response.status_code == 404:
                print(f"❌ 题目不存在: {problem_id}")
                return None
            else:
                print(f"❌ 请求失败 {response.status_code}: {problem_id}")
                return None
        except Exception as e:
            print(f"❌ 爬取失败 {problem_id}: {e}")
            return None
    def parse_problem_page(self, html, problem_id):
        """解析题目页面"""
        soup = BeautifulSoup(html, 'html.parser')
        # 提取标题
        title = self.extract_title(soup, problem_id)
        # 提取描述
        description = self.extract_description(soup)
        # 提取难度和标签
        difficulty, tags = self.extract_metadata(soup)
        return {
            'id': problem_id,
            'platform': 'luogu',
            'title': title,
            'description': description,
            'difficulty': difficulty,
            'tags': tags,
            'url': f"{self.base_url}/problem/{problem_id}"
        }

    def extract_title(self, soup, problem_id):
        """提取题目标题"""
        # 尝试多种选择器
        selectors = [
            'h1.title', 
            '.problem-title',
            'title',
            'h1'
        ]

        for selector in selectors:
            element = soup.select_one(selector)
            if element:
                title = element.get_text().strip()
                if title and title != problem_id:
                    return title

        # 如果没找到,返回默认标题
        return f"题目 {problem_id}"

    def extract_description(self, soup):
        """提取题目描述"""
        selectors = [
            '.problem-content',
            '.content',
            'article',
            '.card'
        ]

        for selector in selectors:
            element = soup.select_one(selector)
            if element:
                text = element.get_text().strip()
                if len(text) > 50:  # 确保有足够内容
                    return text[:1000]  # 限制长度

        return "暂无描述"

    def extract_metadata(self, soup):
        """提取难度和标签"""
        difficulty = "未知"
        tags = []

        # 查找难度
        diff_selectors = ['.difficulty', '.level', '.tag']
        for selector in diff_selectors:
            elements = soup.select(selector)
            for elem in elements:
                text = elem.get_text().strip()
                if any(word in text for word in ['入门', '普及', '提高', '省选', 'NOI']):
                    difficulty = text
                    break
            if difficulty != "未知":
                break

        # 查找标签
        tag_selectors = ['.tag', '.label', '.badge']
        for selector in tag_selectors:
            elements = soup.select(selector)
            for elem in elements:
                text = elem.get_text().strip()
                if text and text not in tags and text != difficulty:
                    tags.append(text)

        return difficulty, tags[:5]  # 最多5个标签

它会把题目的各种信息都保存在 problems.db 中。

2.Query

现在要查询一个问题,当然不能通过简单的比较题面来,因为每个问题都会有许多种可能的题面。

我们可以首先通过把所有题目都转换成一个标准表述,方便进行向量化搜索。\ 这个搜索方式大概就是把问题或者说内容转化为类似空间向量的方式表述,然后通过向量的区别来得出内容的关联性。

但是要真的做到这个比较复杂且不免费,需要购买向量化的功能,但是因为我们查询的都是 OI 的问题,所以可以把每个问题标准化并打上标签和难度。

3.Processing data

首先要处理爬下来的题目使它们方便查询,也就是把题目向量化。 这里是向量化题目的关键部分:

 def get_embedding(self, text):
        """获取文本的向量嵌入"""
        # 限制文本长度
        text = text[:8000]

        response = self.client.embeddings.create(
            model="text-embedding-ada-002",
            input=text
        )
        return response.data[0].embedding
 def analyze_query(self, user_query):
        """AI分析查询意图"""
        prompt = f"""
        用户描述了一个编程问题:"{user_query}"

        请分析这个问题的核心算法要素,返回JSON格式:
        {{
            "algorithm_type": ["算法类型列表"],
            "search_keywords": ["搜索关键词列表"], 
            "difficulty": "难度级别",
            "core_operation": "核心操作描述"
        }}

        示例:
        用户查询:"找两个数相乘的题目"
        输出:{{
            "algorithm_type": ["基础运算"],
            "search_keywords": ["乘法", "乘积", "A*B Problem", "高精度乘法"],
            "difficulty": "入门",
            "core_operation": "计算两个整数的乘积"
        }}
        """

        try:
            response = self.vector_search.client.chat.completions.create(
                model="gpt-4.1-nano",
                messages=[{"role": "user", "content": prompt}],
                max_tokens=300,
                temperature=0.1
            )

            result = response.choices[0].message.content
            # 提取JSON
            import re
            json_match = re.search(r'\{.*\}', result, re.DOTALL)
            if json_match:
                return json.loads(json_match.group())
            else:
                return {"search_keywords": [user_query]}

        except Exception as e:
            print(f"❌ 查询分析失败,使用默认关键词: {e}")
            return {"search_keywords": [user_query]}

每道题记得再向量化后保存回题库,同时,使用 gpt-4.1-nano 超级便宜且准确