fix: preserve production task and auth integrations

This commit is contained in:
2026-08-19 16:23:39 +08:00
parent 2da7f4f328
commit ca8611113e
5 changed files with 326 additions and 65 deletions

88
app.py
View File

@@ -6,7 +6,9 @@ from flask import Flask, g, jsonify, redirect, request, send_file
from auth_service import ( from auth_service import (
build_dingtalk_login_url, build_dingtalk_login_url,
create_agent_access_token,
exchange_dingtalk_user, exchange_dingtalk_user,
get_user_by_agent_token,
get_user_by_session_token, get_user_by_session_token,
revoke_session, revoke_session,
upsert_user_and_session, upsert_user_and_session,
@@ -32,10 +34,12 @@ from task_service import (
TaskError, TaskError,
cancel_task, cancel_task,
create_task, create_task,
delete_task,
get_file, get_file,
get_task, get_task,
get_task_extracted, get_task_extracted,
list_tasks, list_tasks,
merge_tasks,
recollect_task, recollect_task,
resume_task, resume_task,
resume_unfinished_tasks, resume_unfinished_tasks,
@@ -50,12 +54,8 @@ def create_app() -> Flask:
if not Config.LOCAL_AUTH_BYPASS or request.remote_addr not in {"127.0.0.1", "::1"}: if not Config.LOCAL_AUTH_BYPASS or request.remote_addr not in {"127.0.0.1", "::1"}:
return None return None
return { return {
"id": "local-dev", "id": "local-dev", "name": "本地开发用户", "account": "local-dev",
"name": "本地开发用户", "avatar": "", "union_id": "", "open_id": "",
"account": "local-dev",
"avatar": "",
"union_id": "",
"open_id": "",
} }
@app.before_request @app.before_request
@@ -67,9 +67,16 @@ def create_app() -> Flask:
if request.path == "/health" or any(request.path.startswith(path) for path in open_paths): if request.path == "/health" or any(request.path.startswith(path) for path in open_paths):
return None return None
user = local_dev_user() or get_user_by_session_token(request.cookies.get(Config.SESSION_COOKIE_NAME)) user = local_dev_user() or get_user_by_session_token(request.cookies.get(Config.SESSION_COOKIE_NAME))
if user is None:
authorization = request.headers.get("Authorization", "")
if authorization.lower().startswith("bearer "):
user = get_user_by_agent_token(authorization[7:].strip())
if user is None and request.path.startswith("/api/"): if user is None and request.path.startswith("/api/"):
return jsonify({ return jsonify({
"error": {"code": "UNAUTHORIZED", "message": "请先通过钉钉授权登录"}, "error": {
"code": "UNAUTHORIZED",
"message": "登录或 AI 访问 Token 已失效,请重新登录后复制 Link AI",
},
"request_id": g.request_id, "request_id": g.request_id,
}), 401 }), 401
g.current_user = user g.current_user = user
@@ -104,10 +111,14 @@ def create_app() -> Flask:
@app.route("/api/v1/auth/dingtalk/login", methods=["GET"]) @app.route("/api/v1/auth/dingtalk/login", methods=["GET"])
def dingtalk_login(): def dingtalk_login():
return jsonify({ response = jsonify({
"data": build_dingtalk_login_url(request.args.get("redirect", "/tasks")), "data": build_dingtalk_login_url(request.args.get("redirect", "/tasks")),
"request_id": g.request_id, "request_id": g.request_id,
}) })
# OAuth URL 带一次性 state且 scope/corpId 配置可能更新,禁止缓存。
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
@app.route("/api/v1/auth/dingtalk/callback", methods=["GET"]) @app.route("/api/v1/auth/dingtalk/callback", methods=["GET"])
def dingtalk_callback(): def dingtalk_callback():
@@ -118,8 +129,13 @@ def create_app() -> Flask:
frontend_redirect = state.split(":", 1)[1] or frontend_redirect frontend_redirect = state.split(":", 1)[1] or frontend_redirect
if not code: if not code:
return redirect(f"{Config.FRONTEND_BASE_URL}/login?error={quote('钉钉授权缺少 code')}") return redirect(f"{Config.FRONTEND_BASE_URL}/login?error={quote('钉钉授权缺少 code')}")
try:
user_payload = exchange_dingtalk_user(code) user_payload = exchange_dingtalk_user(code)
user, token, expires_at = upsert_user_and_session(user_payload) user, token, expires_at = upsert_user_and_session(user_payload)
except TaskError as exc:
return redirect(
f"{Config.FRONTEND_BASE_URL}/login?error={quote(exc.message)}"
)
response = redirect(f"{Config.FRONTEND_BASE_URL}{frontend_redirect}") response = redirect(f"{Config.FRONTEND_BASE_URL}{frontend_redirect}")
response.set_cookie( response.set_cookie(
Config.SESSION_COOKIE_NAME, Config.SESSION_COOKIE_NAME,
@@ -130,17 +146,6 @@ def create_app() -> Flask:
samesite=Config.SESSION_COOKIE_SAMESITE, samesite=Config.SESSION_COOKIE_SAMESITE,
path="/", path="/",
) )
# 给前端“Link AI”复制提示词使用。后端鉴权仍读取 hc_session
# 前端只读取这个辅助 Cookie 的 value并在提示词中拼成 hc_session=<value>。
response.set_cookie(
"hc_session_agent",
token,
expires=expires_at,
httponly=False,
secure=Config.SESSION_COOKIE_SECURE,
samesite=Config.SESSION_COOKIE_SAMESITE,
path="/",
)
app.logger.info("钉钉用户登录成功 user_id=%s name=%s", user["id"], user["name"]) app.logger.info("钉钉用户登录成功 user_id=%s name=%s", user["id"], user["name"])
return response return response
@@ -216,11 +221,34 @@ def create_app() -> Flask:
raise TaskError("UNAUTHORIZED", "请先通过钉钉授权登录", 401) raise TaskError("UNAUTHORIZED", "请先通过钉钉授权登录", 401)
return jsonify({"data": {"user": user}, "request_id": g.request_id}) return jsonify({"data": {"user": user}, "request_id": g.request_id})
@app.route("/api/v1/auth/agent-token", methods=["POST"])
def auth_agent_token():
issued = create_agent_access_token(
request.cookies.get(Config.SESSION_COOKIE_NAME)
)
if not issued:
raise TaskError(
"UNAUTHORIZED",
"登录已失效,请重新完成钉钉登录后再复制 Link AI",
401,
)
token, expires_at = issued
response = jsonify({
"data": {
"token": token,
"expires_at": expires_at.isoformat(timespec="seconds"),
},
"request_id": g.request_id,
})
response.headers["Cache-Control"] = "no-store"
return response
@app.route("/api/v1/auth/logout", methods=["POST"]) @app.route("/api/v1/auth/logout", methods=["POST"])
def auth_logout(): def auth_logout():
revoke_session(request.cookies.get(Config.SESSION_COOKIE_NAME)) revoke_session(request.cookies.get(Config.SESSION_COOKIE_NAME))
response = jsonify({"data": {"ok": True}, "request_id": g.request_id}) response = jsonify({"data": {"ok": True}, "request_id": g.request_id})
response.delete_cookie(Config.SESSION_COOKIE_NAME, path="/") response.delete_cookie(Config.SESSION_COOKIE_NAME, path="/")
# 清理旧版本曾设置的可读辅助 Cookie。
response.delete_cookie("hc_session_agent", path="/") response.delete_cookie("hc_session_agent", path="/")
return response return response
@@ -272,12 +300,27 @@ def create_app() -> Flask:
) )
return jsonify({"data": result, "request_id": g.request_id}) return jsonify({"data": result, "request_id": g.request_id})
@app.route("/api/v1/tasks/merge", methods=["POST"])
def merge_research_tasks():
payload = request.get_json(silent=True) or {}
task_ids = payload.get("task_ids")
if not isinstance(task_ids, list):
raise TaskError("MERGE_TASKS_REQUIRED", "task_ids 必须是任务 ID 数组", 422)
return jsonify({
"data": merge_tasks(task_ids),
"request_id": g.request_id,
})
@app.route("/api/v1/tasks/<task_id>", methods=["GET"]) @app.route("/api/v1/tasks/<task_id>", methods=["GET"])
def get_research_task(task_id: str): def get_research_task(task_id: str):
if request.args.get("view") in {"extracted", "cleaned", "parsed"}: if request.args.get("view") in {"extracted", "cleaned", "parsed"}:
return jsonify({"data": get_task_extracted(task_id), "request_id": g.request_id}) return jsonify({"data": get_task_extracted(task_id), "request_id": g.request_id})
return jsonify({"data": get_task(task_id), "request_id": g.request_id}) return jsonify({"data": get_task(task_id), "request_id": g.request_id})
@app.route("/api/v1/tasks/<task_id>", methods=["DELETE"])
def delete_research_task(task_id: str):
return jsonify({"data": delete_task(task_id), "request_id": g.request_id})
@app.route("/api/v1/tasks/<task_id>/extracted", methods=["GET"]) @app.route("/api/v1/tasks/<task_id>/extracted", methods=["GET"])
def get_research_task_extracted(task_id: str): def get_research_task_extracted(task_id: str):
return jsonify({"data": get_task_extracted(task_id), "request_id": g.request_id}) return jsonify({"data": get_task_extracted(task_id), "request_id": g.request_id})
@@ -297,8 +340,13 @@ def create_app() -> Flask:
@app.route("/api/v1/tasks/<task_id>/recollect", methods=["POST"]) @app.route("/api/v1/tasks/<task_id>/recollect", methods=["POST"])
def recollect_research_task(task_id: str): def recollect_research_task(task_id: str):
current_user = getattr(g, "current_user", None) or {}
return jsonify({ return jsonify({
"data": recollect_task(task_id), "data": recollect_task(
task_id,
created_by_id=current_user.get("id"),
created_by_name=current_user.get("name"),
),
"request_id": g.request_id, "request_id": g.request_id,
}), 201 }), 201

View File

@@ -35,10 +35,19 @@ class Config:
LOCAL_AUTH_BYPASS = os.getenv("LOCAL_AUTH_BYPASS", "false").lower() in ("1", "true", "yes", "on") LOCAL_AUTH_BYPASS = os.getenv("LOCAL_AUTH_BYPASS", "false").lower() in ("1", "true", "yes", "on")
DINGTALK_CLIENT_ID = os.getenv("DINGTALK_CLIENT_ID", os.getenv("DINGTALK_APP_KEY", "")) DINGTALK_CLIENT_ID = os.getenv("DINGTALK_CLIENT_ID", os.getenv("DINGTALK_APP_KEY", ""))
DINGTALK_CLIENT_SECRET = os.getenv("DINGTALK_CLIENT_SECRET", os.getenv("DINGTALK_APP_SECRET", "")) DINGTALK_CLIENT_SECRET = os.getenv("DINGTALK_CLIENT_SECRET", os.getenv("DINGTALK_APP_SECRET", ""))
DINGTALK_ALLOWED_CORP_ID = os.getenv(
"DINGTALK_ALLOWED_CORP_ID",
"dingbc8e63f8aa03221d35c2f4657eb6378f",
)
DINGTALK_REDIRECT_URI = os.getenv( DINGTALK_REDIRECT_URI = os.getenv(
"DINGTALK_REDIRECT_URI", "DINGTALK_REDIRECT_URI",
f"{FRONTEND_BASE_URL}/api/v1/auth/dingtalk/callback", f"{FRONTEND_BASE_URL}/api/v1/auth/dingtalk/callback",
) )
CRAWLER_DATABASE_HOST = os.getenv("CRAWLER_DATABASE_HOST", "")
CRAWLER_DATABASE_PORT = int(os.getenv("CRAWLER_DATABASE_PORT", "3306"))
CRAWLER_DATABASE_USER = os.getenv("CRAWLER_DATABASE_USER", "")
CRAWLER_DATABASE_PASSWORD = os.getenv("CRAWLER_DATABASE_PASSWORD", "")
CRAWLER_DATABASE_NAME = os.getenv("CRAWLER_DATABASE_NAME", "")
DINGTALK_AUTH_URL = os.getenv("DINGTALK_AUTH_URL", "https://login.dingtalk.com/oauth2/auth") DINGTALK_AUTH_URL = os.getenv("DINGTALK_AUTH_URL", "https://login.dingtalk.com/oauth2/auth")
DINGTALK_TOKEN_URL = os.getenv("DINGTALK_TOKEN_URL", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken") DINGTALK_TOKEN_URL = os.getenv("DINGTALK_TOKEN_URL", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
DINGTALK_USER_URL = os.getenv("DINGTALK_USER_URL", "https://api.dingtalk.com/v1.0/contact/users/me") DINGTALK_USER_URL = os.getenv("DINGTALK_USER_URL", "https://api.dingtalk.com/v1.0/contact/users/me")

View File

@@ -1,8 +1,10 @@
import re import re
import secrets
import time import time
from typing import Any from typing import Any
import requests import requests
import json
from config import Config from config import Config
@@ -11,6 +13,10 @@ class CrawlerError(RuntimeError):
pass pass
class NoAiCardError(CrawlerError):
"""搜索请求成功,但当前结果中没有问一问 AI 卡片。"""
CRAWLER_SESSION = requests.Session() CRAWLER_SESSION = requests.Session()
# 爬虫服务为明确配置的直连地址,不应继承 macOS 系统代理。 # 爬虫服务为明确配置的直连地址,不应继承 macOS 系统代理。
# 否则 requests 会经由 127.0.0.1:7890约 30 秒后被代理返回 502。 # 否则 requests 会经由 127.0.0.1:7890约 30 秒后被代理返回 502。
@@ -30,6 +36,58 @@ def normalize_collection_keywords(keywords: list[str]) -> list[str]:
return normalized return normalized
def compact_search_keyword(keyword: str) -> str:
"""生成问一问卡片匹配的无空白回退词。
小红书会把 ``ai 生成 ppt 哪个好用`` 和 ``ai生成ppt哪个好用`` 当成
不同搜索词,前者可能不返回 AI 卡片。仅在原词明确无卡片时使用该
回退,不改变任务对外展示的原始关键词。
"""
return re.sub(r"\s+", "", keyword)
def fallback_search_keywords(keyword: str) -> list[str]:
"""Generate conservative query variants when the original query misses the AI card."""
variants: list[str] = []
def add(value: str) -> None:
value = re.sub(r"\s+", " ", value).strip()
if value and value != keyword and value not in variants:
variants.append(value)
add(compact_search_keyword(keyword))
# “aippt自动生成哪个好”在搜索端可能无法正确分词而等价的自然分词
# “ai 生成 ppt 哪个好用”可以命中相同意图下的问一问 AI 卡片。
spaced = re.sub(
r"^ai\s*ppt\s*自动生成",
"ai 生成 ppt ",
keyword,
flags=re.IGNORECASE,
)
spaced = re.sub(r"哪个好$", "哪个好用", spaced)
add(spaced)
return variants
def create_search_context_id() -> str:
"""生成小红书搜索链路使用的 36 进制上下文 ID。
与 Web 端 createSearchId 的算法保持一致:
``(Date.now() << 64) + random(1..2147483646)``。
"""
value = (int(time.time() * 1000) << 64) + secrets.randbelow(2147483646) + 1
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
encoded = ""
while value:
value, remainder = divmod(value, 36)
encoded = alphabet[remainder] + encoded
return encoded or "0"
def _post( def _post(
path: str, path: str,
body: dict[str, Any], body: dict[str, Any],
@@ -71,13 +129,160 @@ def _post(
raise CrawlerError(f"爬虫服务请求失败(已重试 {retry_times} 次)") raise CrawlerError(f"爬虫服务请求失败(已重试 {retry_times} 次)")
if payload.get("code") != 200 or not payload.get("success", False): if payload.get("code") != 200 or not payload.get("success", False):
raise CrawlerError(payload.get("msg") or "爬虫服务返回失败") message = str(payload.get("msg") or "爬虫服务返回失败")
no_ai_card_signals = (
"没有返回问一问 AI 卡片",
"NoneType' object has no attribute 'get'",
"complex/detail 未返回 AI 数据",
)
if any(signal in message for signal in no_ai_card_signals):
raise NoAiCardError(message)
raise CrawlerError(message)
data = payload.get("data") data = payload.get("data")
if not isinstance(data, dict): if not isinstance(data, dict):
raise CrawlerError("爬虫服务响应缺少 data") raise CrawlerError("爬虫服务响应缺少 data")
return data return data
def start_collection(mode: str, keywords: list[str]) -> dict[str, Any]:
"""深度任务走 sources常规任务走 overview。"""
collection_keywords = normalize_collection_keywords(keywords)
path = "/api/v1/xhs/wen/sources" if mode == "deep" else "/api/v1/xhs/wen/overview"
# 常规任务允许批量关键词;数组由远程 overview 创建异步批量任务。
keyword: str | list[str] = collection_keywords if len(collection_keywords) > 1 else collection_keywords[0]
# 轻度 overview 在首次计算超过网关时限后,远程通常已经写好缓存,
# 允许受控重试以取得结果;深度 sources 会启动长任务,禁止盲目重试,
# 避免远程请求虽断开但后台继续执行时产生重复采集任务。
# 采集服务的 search/notes 需要搜索上下文,但业务调用方只应传 keyword。
# 在 Holy蟹内部为每个新任务生成一次并由同一个远程采集任务全程复用。
def submit(value: str | list[str]) -> dict[str, Any]:
return _post(
path,
{
"keyword": value,
"search_id": create_search_context_id(),
"session_id": create_search_context_id(),
},
retry_transient=(mode != "deep"),
)
try:
return submit(keyword)
except NoAiCardError:
# 搜索端分词差异可能让真实存在的问一问卡片暂时无法命中。
# 依次尝试保守的等价写法;全部失败后才交给上层按空结果完成。
if isinstance(keyword, str):
for fallback_keyword in fallback_search_keywords(keyword):
try:
result = submit(fallback_keyword)
except NoAiCardError:
continue
result["requested_keyword"] = keyword
result["collection_keyword"] = fallback_keyword
return result
raise
def query_collection(crawler_task_id: str) -> dict[str, Any]:
return _post("/api/v1/xhs/wen/tasks/query", {"task_id": crawler_task_id})
def recover_source_note_details(keyword: str) -> dict[str, Any]:
"""从采集器已落库的 complex/source 记录恢复可分析的来源 notes。"""
if not all((
Config.CRAWLER_DATABASE_HOST,
Config.CRAWLER_DATABASE_USER,
Config.CRAWLER_DATABASE_NAME,
)):
return {}
import pymysql
db = pymysql.connect(
host=Config.CRAWLER_DATABASE_HOST,
port=Config.CRAWLER_DATABASE_PORT,
user=Config.CRAWLER_DATABASE_USER,
password=Config.CRAWLER_DATABASE_PASSWORD,
database=Config.CRAWLER_DATABASE_NAME,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
try:
with db.cursor() as cursor:
cursor.execute(
"SELECT searchid,card_id FROM xhs_wen_ai_answers "
"WHERE keyword=%s ORDER BY updated_at DESC,id DESC LIMIT 1",
(keyword,),
)
linkage = cursor.fetchone()
if not linkage:
return {}
cursor.execute(
"SELECT n.raw_json,n.tag_name,t.title AS product_name "
"FROM xhs_wen_tag_notes n LEFT JOIN xhs_wen_ai_tags t "
"ON t.keyword=n.keyword AND t.searchid=n.searchid "
"AND t.card_id=n.card_id AND t.entity_id=n.entity_id "
"AND t.tag_name=n.tag_name "
"WHERE n.keyword=%s AND n.searchid=%s AND n.card_id=%s "
"ORDER BY n.id",
(keyword, linkage["searchid"], linkage["card_id"]),
)
rows = cursor.fetchall()
finally:
db.close()
grouped: dict[str, dict[str, list[dict[str, Any]]]] = {}
for row in rows:
try:
source = json.loads(row.get("raw_json") or "{}")
except (TypeError, ValueError):
continue
note = source.get("note_info") if isinstance(source.get("note_info"), dict) else {}
if not note:
continue
note = dict(note)
note.setdefault("type", source.get("type") or note.get("type"))
note.setdefault(
"wechat_share_desc",
source.get("wechat_share_desc")
or note.get("paragraph_text")
or note.get("relevant_text"),
)
product = str(row.get("product_name") or "")
tag = str(row.get("tag_name") or "")
grouped.setdefault(product, {}).setdefault(tag, []).append(note)
products = [
{
"product_name": product,
"content_tags": [
{"content_tag": tag, "count": len(notes), "notes": notes}
for tag, notes in tags.items()
],
}
for product, tags in grouped.items()
]
count = sum(
len(tag["notes"])
for product in products for tag in product["content_tags"]
)
return {
"requested_count": count,
"success_count": count,
"error_count": 0,
"errors": [],
"products": products,
"recovered_from": "xhs_wen_tag_notes",
}
def cancel_collection(crawler_task_id: str) -> dict[str, Any]:
"""通知爬虫任务在下一个安全检查点停止并保存部分结果。"""
return _post(f"/api/v1/xhs/wen/tasks/{crawler_task_id}/cancel", {})
def _get(path: str) -> dict[str, Any]: def _get(path: str) -> dict[str, Any]:
url = f"{Config.CRAWLER_BASE_URL}{path}" url = f"{Config.CRAWLER_BASE_URL}{path}"
try: try:
@@ -101,46 +306,8 @@ def get_wen_resources() -> dict[str, Any]:
return _get("/api/v1/xhs/wen/resources") return _get("/api/v1/xhs/wen/resources")
def start_collection(mode: str, keywords: list[str]) -> dict[str, Any]:
"""深度任务走 sources轻度/常规任务走 overview。"""
collection_keywords = normalize_collection_keywords(keywords)
path = "/api/v1/xhs/wen/sources" if mode == "deep" else "/api/v1/xhs/wen/overview"
# 远程 overview 只有接收数组时才创建异步任务并立即返回 task_id。
# 因此轻度任务即使只有一个关键词也必须传数组,避免单关键词走同步
# overview 并因采集耗时超过网关时限而返回 502。
keyword: str | list[str] = (
collection_keywords if mode == "mild" else collection_keywords[0]
)
# 轻度 overview 在首次计算超过网关时限后,远程通常已经写好缓存,
# 允许受控重试以取得结果;深度 sources 会启动长任务,禁止盲目重试,
# 避免远程请求虽断开但后台继续执行时产生重复采集任务。
return _post(
path,
{"keyword": keyword},
retry_transient=(mode != "deep"),
)
def query_collection(crawler_task_id: str) -> dict[str, Any]:
return _post("/api/v1/xhs/wen/tasks/query", {"task_id": crawler_task_id})
def cancel_collection(crawler_task_id: str) -> dict[str, Any]:
"""通知爬虫任务在下一个安全检查点停止并保存部分结果。"""
return _post(f"/api/v1/xhs/wen/tasks/{crawler_task_id}/cancel", {})
def resume_collection(crawler_task_id: str) -> dict[str, Any]:
"""沿用原爬虫任务 ID从 framework 保存的检查点继续采集。"""
return _post(f"/api/v1/xhs/wen/tasks/{crawler_task_id}/resume", {})
def fetch_content_detail(note_url: str) -> dict[str, Any]: def fetch_content_detail(note_url: str) -> dict[str, Any]:
"""按小红书笔记链接读取完整笔记详情。""" """按小红书笔记链接读取完整笔记详情。"""
return _post( return _post(
"/api/v1/fetch_content_detail", "/api/v1/fetch_content_detail",
{"plant": "xhs", "link": note_url}, {"plant": "xhs", "link": note_url},
@@ -149,8 +316,7 @@ def fetch_content_detail(note_url: str) -> dict[str, Any]:
def schedule_xapi_screenshots(search_queries: list[str], device_count: int) -> dict[str, Any]: def schedule_xapi_screenshots(search_queries: list[str], device_count: int) -> dict[str, Any]:
"""向手机集群下发问一问关键词搜索与截图任务。""" """向手机集群下发关键词搜索与截图任务。"""
queries = normalize_collection_keywords(search_queries) queries = normalize_collection_keywords(search_queries)
return _post( return _post(
"/api/v1/xhs/wen/xapi/schedule", "/api/v1/xhs/wen/xapi/schedule",
@@ -160,8 +326,7 @@ def schedule_xapi_screenshots(search_queries: list[str], device_count: int) -> d
def collect_keyword_sources(keyword: str, credential_id: str | None = None) -> dict[str, Any]: def collect_keyword_sources(keyword: str, credential_id: str | None = None) -> dict[str, Any]:
"""使用 sources 的完整 Cookie 采集单个关键词。""" """使用指定 Cookie 采集单个关键词。"""
normalized = normalize_collection_keywords([keyword])[0] normalized = normalize_collection_keywords([keyword])[0]
body: dict[str, Any] = {"keyword": normalized} body: dict[str, Any] = {"keyword": normalized}
if credential_id: if credential_id:
@@ -171,3 +336,9 @@ def collect_keyword_sources(keyword: str, credential_id: str | None = None) -> d
body, body,
retry_transient=False, retry_transient=False,
) )
def resume_collection(crawler_task_id: str) -> dict[str, Any]:
"""沿用原爬虫任务 ID从 framework 保存的检查点继续采集。"""
return _post(f"/api/v1/xhs/wen/tasks/{crawler_task_id}/resume", {})

View File

@@ -85,6 +85,25 @@ def init_database() -> None:
FOREIGN KEY(task_id) REFERENCES research_tasks(id) ON DELETE CASCADE FOREIGN KEY(task_id) REFERENCES research_tasks(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS task_keyword_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
keyword_order INTEGER NOT NULL,
keyword TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
progress_percent INTEGER NOT NULL DEFAULT 0,
crawler_task_id TEXT,
result_json TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL,
UNIQUE(task_id, keyword_order),
FOREIGN KEY(task_id) REFERENCES research_tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_keyword_runs_task_status
ON task_keyword_runs(task_id, status, keyword_order);
CREATE TABLE IF NOT EXISTS task_files ( CREATE TABLE IF NOT EXISTS task_files (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
public_file_id TEXT NOT NULL UNIQUE, public_file_id TEXT NOT NULL UNIQUE,
@@ -134,6 +153,19 @@ def init_database() -> None:
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
ON auth_sessions(user_id, expires_at); ON auth_sessions(user_id, expires_at);
CREATE TABLE IF NOT EXISTS ai_access_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
revoked_at TEXT,
FOREIGN KEY(user_id) REFERENCES auth_users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ai_access_tokens_user
ON ai_access_tokens(user_id, expires_at);
CREATE TABLE IF NOT EXISTS acceptance_tasks ( CREATE TABLE IF NOT EXISTS acceptance_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
public_task_id TEXT NOT NULL UNIQUE, public_task_id TEXT NOT NULL UNIQUE,

View File

@@ -2,4 +2,5 @@ Flask==3.1.1
requests==2.32.4 requests==2.32.4
python-dotenv==1.1.1 python-dotenv==1.1.1
gunicorn==23.0.0 gunicorn==23.0.0
PyMySQL==1.1.1
playwright==1.58.0 playwright==1.58.0