def build_driver(headless: bool = True) -> webdriver.Chrome:
chrome_opts = Options()
if headless:
chrome_opts.add_argument("--headless=new")
chrome_opts.add_argument("--disable-gpu")
chrome_opts.add_argument("--no-sandbox")
chrome_opts.add_argument("--window-size=1400,900")
chrome_opts.add_argument(
'--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
)
chrome_opts.add_argument("--disable-blink-features=AutomationControlled")
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service, options=chrome_opts)
driver.set_page_load_timeout(180)
driver.implicitly_wait(3)
return driver
def human_like_scroll(driver: webdriver.Chrome):
"""Simulate human scrolling behavior, to prevent being too "robotic"."""
try:
# Scroll a few times, each time to a different height, with random pauses in between
scroll_steps = random.randint(2, 5)
for _ in range(scroll_steps):
# 0.3 ~ 1.0 times the page height randomly
factor = random.uniform(0.3, 1.0)
driver.execute_script(
"window.scrollTo(0, document.body.scrollHeight * arguments[0]);",
factor,
)
time.sleep(random.uniform(0.5, 1.5))
except Exception as e:
print("[WARN] human_like_scroll error: ", e)
搜索框键入搜索:
def test_search(url: str, keyword: str):
driver = build_driver(headless=False) # 方便你看到效果
driver.get(url)
time.sleep(1)
# 找到搜索框
search_input = driver.find_element(By.CSS_SELECTOR, ".search-container .sh-inpt input")
search_input.clear()
search_input.send_keys(keyword)
time.sleep(0.3)
# 点击"搜索"按钮(Selenium 4 推荐方式)
search_button = driver.find_element(By.CSS_SELECTOR, ".search-container .sh-btn")
search_button.click()
# 等待页面加载
time.sleep(3)
# 模拟滚动
human_like_scroll(driver)
print("页面标题:", driver.title)
print("当前URL:", driver.current_url)
# 你可在这里加"爬取结果"的代码
# html = driver.page_source
# print(html[:300])
time.sleep(2)
driver.quit()
if __name__ == "__main__":
test_search(
url="http://search.people.cn/", # ← 要测试的网站
keyword="数字化转型" # ← 测试关键词
)
爬虫 + LLM
在本地调用大模型 API,不能对网址链接进行访问。因为大模型本身是语言模型,只能处理文本输入输出,无法直接发起HTTP请求,无法执行浏览器操作。
所以一般的方法是,先用爬虫工具,如 BeautifulSoup4、requests、Selenium 等将文本类型的数据爬取下来,然后将文本数据导入大模型进行语义分析和目标关键数据的提取。
配置环境变量:
from dotenv import load_dotenv
load_dotenv()
读取环境变量:
base_url = os.getenv("AI_BASE_URL", "") # e.g. https://api.aiiai.top/v1
api_key = os.getenv("AI_API_KEY", "")
model = os.getenv("AI_MODEL_TYPE", "") # e.g. gemini-2.5-pro
用 OpenAI 兼容 SDK 调用:
client = OpenAI(
base_url=base_url,
api_key=api_key,
)
completion = client.chat.completions.create(
model=model,
messages=messages,
)
content = completion.choices[0].message.content
-
使用 openai 的 OpenAI 客户端,但是把
base_url改成自己的网关 (https://api.aiiai.top/v1),从而兼容各种自托管 / 代理服务。 -
调用方式是标准的 Chat Completions 接口 (聊天接口):传入
model和messages。具体详见 OpenAI API 文档: https://platform.openai.com/docs/api-reference/chat/create。
OpenAI 文档明确说明请求 JSON 必须包含:
{
"model": "gpt-5.2",
"messages": [
{"role": "system", "content": "..." },
{"role": "user", "content": "..." }
]
}
实例:
def call_local_llm(messages: List[Dict[str, str]]) -> Any:
"""
Call the local / OpenAI compatible model:
- Read AI_BASE_URL / AI_API_KEY / AI_MODEL_TYPE from environment variables
- Default base_url = https://api.aiiai.top/v1
- Default model = gemini-2.5-pro
- Return the Python object (dict / list / None) after JSON parsing
"""
init_logger()
base_url = os.getenv("AI_BASE_URL", "")
api_key = os.getenv("AI_API_KEY", "")
model = os.getenv("AI_MODEL_TYPE", "")
if not base_url:
base_url = "https://api.aiiai.top/v1"
if not model:
model = "gemini-2.5-pro"
if not api_key:
logger.error("API key is empty")
raise ValueError("API key is empty")
client = OpenAI(
base_url=base_url,
api_key=api_key,
)
logger.info(f"Calling LLM, model={model}")
completion = client.chat.completions.create(
model=model,
messages=messages,
)
content = completion.choices[0].message.content
if content is None:
logger.error("LLM returned empty content")
raise RuntimeError("LLM returned empty content")
content = content.strip()
logger.info(f"LLM raw output: {content}")
# Try JSON parsing, compatible with ```json ... ``` wrapped cases
if isinstance(content, str):
try:
return json.loads(content)
except json.JSONDecodeError:
cleaned = content.strip().strip("`")
lower = cleaned.lower()
if lower.startswith("json\n") or lower.startswith("json\r\n"):
cleaned = "\n".join(cleaned.splitlines()[1:])
return json.loads(cleaned)
else:
# It should not reach here, but keep compatible
return content
SDK = Software Development Kit(软件开发工具包)。它是一套官方提供的工具,用来方便开发者调用某个服务。
对于 OpenAI,SDK 封装了 HTTP 请求,不需要自己写复杂的 POST body、headers,SDK 会自动处理错误、重试、流式输出等。
用 SDK 的写法:
completion = client.chat.completions.create(
model=model,
messages=messages,
)
如果不用 SDK,就必须手写 HTTP:
import requests
requests.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "...",
"messages": [...],
}
)
把要分析的文本传入大模型:
-
SYSTEM_PROMPT: 从外部文件加载规则def load_system_prompt():
base_dir = os.path.dirname(os.path.abspath(file))
ht_path = os.path.join(base_dir, "ht_jg.txt")
...
SYSTEM_PROMPT = load_system_prompt() -
messages: 一条一条地喂合同记录def build_messages_for_single_record(
region: str,
organization_name: Optional[str],
contract_record: Dict[str, Any],
) -> List[Dict[str, str]]:
"""
Construct the messages for a single contract record:
- system: system instruction (SYSTEM_PROMPT), which should clearly state: this time only process this one contract
- user: contains region / organization_name / this current contract_record / demo_str
"""
user_payload = {
"region": region,
"organization_name": organization_name or "",
"contract_record": contract_record,
"demo_str": demo_str,
}messages = [ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": ( "下面是本次任务的具体输入参数 JSON(仅包含一条合同记录)," "你只能基于这条记录判断是否与目标地区/机构相关,并抽取结构化信息:\n\n" + json.dumps(user_payload, ensure_ascii=False, indent=2) ), }, ] return messages
逐条处理:每次只给模型一条 contract_record(title/url/date/content),杜绝"混合同"错位。
混合同错位(cross-record hallucination 或 cross-record mixup)指模型在处理一批合同记录时,把 A 合同的 URL、B 合同的内容、C 合同的业务场景混在一起输出。
表现为:
-
输出的 URL ≠ 它根据内容抽取出的业务场景
-
输出内容包含另一个合同的片段
-
模型生成了不存在的合同(虚构 URL)
-
第 N 条合同的输出明显引用了第 N + 1 条文本的信息
你之前遇到的这段就是典型"混合同":模型把不存在的 URL 输出成真实合同,并且内容来自完全不同文章
出现这样的原因是:
-
大模型的 "上下文融合机制"
LLM 的本质是把输入的所有内容当成一个统一的语境进行概率预测。
这意味着:如果你给模型一次性输入了 200 条合同,模型不会理解"这是 200 条独立样本",它会认为这是"一个巨大的语料库",并在其中"寻找它认为合理的关联"。
换句话说:LLM 不天然支持"一条一条分开处理"这个概念。批量输入 = 让模型混淆边界 = 产生错位