# -*- coding: utf-8 -*- """ MySQL 记录层: 任务 / 运行 / 爬取结果 写入数据库 - 网页完整内容不入库 (只存磁盘文件), 库中只存标题、网址、状态、文件路径等元数据 - 爬取成功/失败均有 status 标记 (OK / FAIL), 运行记录有 run 状态标记 - 所有操作容错: 数据库不可用时不影响爬取主流程 (仅打印日志) """ import json import os import time import pymysql DB_CONFIG = dict( host="121.40.164.32", port=16006, user="uni_crawler", password="wleD6x2T", charset="utf8mb4", ) DB_NAME = "uni_crawler" DDL = [ """ CREATE TABLE IF NOT EXISTS crawl_tasks ( id VARCHAR(32) PRIMARY KEY, name VARCHAR(200) NOT NULL, mode VARCHAR(20) NOT NULL, config TEXT, urls TEXT, auto_config TEXT, schedule_config TEXT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, """ CREATE TABLE IF NOT EXISTS crawl_runs ( id VARCHAR(32) PRIMARY KEY, task_id VARCHAR(32) NOT NULL, mode VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'running', total INT DEFAULT 0, done INT DEFAULT 0, ok_count INT DEFAULT 0, fail_count INT DEFAULT 0, image_count INT DEFAULT 0, started_at DATETIME NULL, finished_at DATETIME NULL, out_dir VARCHAR(500), KEY idx_task (task_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, """ CREATE TABLE IF NOT EXISTS crawl_results ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id VARCHAR(32) NOT NULL, task_id VARCHAR(32) NOT NULL, mode VARCHAR(20), url VARCHAR(2000) NOT NULL, title VARCHAR(500), status VARCHAR(10) NOT NULL, error TEXT, crawl_time DATETIME, source_url VARCHAR(2000), depth INT, attempts INT DEFAULT 1, base_file VARCHAR(500), image_count INT DEFAULT 0, image_files TEXT, UNIQUE KEY uk_run_url (run_id, url(500)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, ] def _migrate(conn): """存量表结构迁移: 合并 html_file/txt_file/meta_file 为 base_file""" with conn.cursor() as cur: cur.execute("SHOW COLUMNS FROM crawl_results LIKE 'html_file'") if cur.fetchone(): cur.execute("ALTER TABLE crawl_results ADD COLUMN base_file VARCHAR(500) NULL AFTER meta_file") cur.execute("UPDATE crawl_results SET base_file = REPLACE(html_file, '.html', '') WHERE base_file IS NULL") cur.execute("ALTER TABLE crawl_results DROP COLUMN html_file, DROP COLUMN txt_file, DROP COLUMN meta_file") print("[db] 表结构迁移完成: html_file/txt_file/meta_file -> base_file") def _conn(): cfg = dict(DB_CONFIG) cfg["database"] = DB_NAME return pymysql.connect(**cfg, autocommit=True, connect_timeout=5) def _safe(fn, *args, **kwargs): """执行数据库操作, 失败仅打印日志不抛出""" try: fn(*args, **kwargs) except Exception as e: print(f"[db] 操作失败: {e}", flush=True) def init_db(): """建库建表 (幂等)""" try: conn = pymysql.connect(**DB_CONFIG, autocommit=True, connect_timeout=5) with conn.cursor() as cur: cur.execute(f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` DEFAULT CHARACTER SET utf8mb4") conn.close() conn = _conn() _migrate(conn) with conn.cursor() as cur: for ddl in DDL: cur.execute(ddl) conn.close() print("[db] 数据库初始化完成 (crawler: crawl_tasks / crawl_runs / crawl_results)") except Exception as e: print(f"[db] 数据库初始化失败: {e}", flush=True) # ---------------- 序列化工具 ---------------- def _dt(v): """datetime 字符串 -> MySQL DATETIME (无效返回 None)""" if not v: return None s = str(v).replace("T", " ") if len(s) >= 19: return s[:19] return None def _j(v): return json.dumps(v, ensure_ascii=False) if v else None # ---------------- 任务 ---------------- def upsert_task(task): """任务写入/更新 (含回收站状态 deleted_at)""" def _do(): conn = _conn() with conn.cursor() as cur: cur.execute( """INSERT INTO crawl_tasks (id, name, mode, config, urls, auto_config, schedule_config, created_at, updated_at, deleted_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE name=%s, mode=%s, config=%s, urls=%s, auto_config=%s, schedule_config=%s, updated_at=%s, deleted_at=%s""", ( task["id"], task.get("name", ""), task.get("mode", ""), _j(task.get("config")), _j(task.get("urls")), _j(task.get("auto")), _j(task.get("schedule")), _dt(task.get("created_at")), _dt(task.get("updated_at")), _dt(task.get("deleted_at")), task.get("name", ""), task.get("mode", ""), _j(task.get("config")), _j(task.get("urls")), _j(task.get("auto")), _j(task.get("schedule")), _dt(task.get("updated_at")), _dt(task.get("deleted_at")), ), ) conn.close() _safe(_do) def purge_task_db(task_id): """彻底删除任务记录""" def _do(): conn = _conn() with conn.cursor() as cur: cur.execute("DELETE FROM crawl_results WHERE task_id=%s", (task_id,)) cur.execute("DELETE FROM crawl_runs WHERE task_id=%s", (task_id,)) cur.execute("DELETE FROM crawl_tasks WHERE id=%s", (task_id,)) conn.close() _safe(_do) def purge_trash_db(): """清空回收站 (删除所有已标记删除的任务记录)""" def _do(): conn = _conn() with conn.cursor() as cur: cur.execute("SELECT id FROM crawl_tasks WHERE deleted_at IS NOT NULL") ids = [r[0] for r in cur.fetchall()] for tid in ids: cur.execute("DELETE FROM crawl_results WHERE task_id=%s", (tid,)) cur.execute("DELETE FROM crawl_runs WHERE task_id=%s", (tid,)) cur.execute("DELETE FROM crawl_tasks WHERE id=%s", (tid,)) conn.close() _safe(_do) # ---------------- 运行记录 ---------------- def upsert_run(run): """运行记录写入/更新""" def _do(): conn = _conn() st = run.get("stats") or {} with conn.cursor() as cur: cur.execute( """INSERT INTO crawl_runs (id, task_id, mode, status, total, done, ok_count, fail_count, image_count, started_at, finished_at, out_dir) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE status=%s, total=%s, done=%s, ok_count=%s, fail_count=%s, image_count=%s, finished_at=%s, out_dir=%s""", ( run["id"], run.get("task_id", ""), run.get("mode", ""), run.get("status", ""), run.get("progress", {}).get("total", 0), run.get("progress", {}).get("done", 0), st.get("ok", 0), st.get("fail", 0), st.get("images", 0), _dt(run.get("started_at")), _dt(run.get("finished_at")), run.get("out_dir", ""), run.get("status", ""), run.get("progress", {}).get("total", 0), run.get("progress", {}).get("done", 0), st.get("ok", 0), st.get("fail", 0), st.get("images", 0), _dt(run.get("finished_at")), run.get("out_dir", ""), ), ) conn.close() _safe(_do) # ---------------- 爬取结果 (每页一条, 成功失败均记录) ---------------- def _base_of(entry): """从结果条目提取基础文件名 (html/txt/meta 三个后缀共用同一前缀)""" for f in (entry.get("meta_file"), entry.get("html_file"), entry.get("txt_file")): f = f or "" if f.endswith(".meta.json"): return f[:-10] if f.endswith(".html"): return f[:-5] if f.endswith(".txt"): return f[:-4] return "" def insert_results(run, results): """批量插入爬取结果 (增量); 文件只记基础名 base_file""" if not results: return def _do(): conn = _conn() rows = [] for r in results: rows.append(( run["id"], run.get("task_id", ""), run.get("mode", ""), (r.get("url") or "")[:2000], (r.get("title") or "")[:500], r.get("status", "FAIL"), r.get("error"), _dt(r.get("crawl_time")), (r.get("source_url") or "")[:2000], r.get("depth"), r.get("attempts", 1), _base_of(r), len(r.get("images", []) or []), _j([im.get("file") for im in (r.get("images") or [])]), )) with conn.cursor() as cur: cur.executemany( """INSERT IGNORE INTO crawl_results (run_id, task_id, mode, url, title, status, error, crawl_time, source_url, depth, attempts, base_file, image_count, image_files) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", rows, ) conn.close() _safe(_do) def sync_run(run, persist): """持久化回调: 同步运行记录 + 增量同步爬取结果 persist: callable(task_id, run) 用于回写已同步进度标记 """ upsert_run(run) results = run.get("results", []) synced = run.get("_db_count", 0) if len(results) > synced: insert_results(run, results[synced:]) run["_db_count"] = len(results) persist(run.get("task_id"), run) # ---------------- 历史数据回填 (幂等) ---------------- def sync_all_history(task_ids=None): """把本地 JSON 中的历史任务/运行/结果全量回填数据库 可重复执行 (INSERT IGNORE + 唯一键去重) task_ids: 指定只回填的任务ID列表, 默认全部 返回统计 dict """ import store as _store stats = {"tasks": 0, "runs": 0, "results": 0} for task in _store.load_tasks(): if task_ids and task["id"] not in task_ids: continue upsert_task(task) stats["tasks"] += 1 for run in _store.get_runs(task["id"]): upsert_run(run) stats["runs"] += 1 results = run.get("results", []) if results: insert_results(run, results) stats["results"] += len(results) run["_db_count"] = len(results) _store.save_run(task["id"], run) # 记录已同步标记, 避免运行中重复插入 return stats