crawler-intra-mart/storage/metadata_db.py
Do Duy eb46e739e7 Add intra-mart documentation crawler
Playwright-based crawler that renders pages from api.intra-mart.jp and
document.intra-mart.jp, extracts main content, and converts to Markdown
via Pandoc. SQLite-backed queue drives a resumable sequential pipeline
across crawler, extractor, converter, and storage modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:29:54 +07:00

935 lines
19 KiB
Python

import json
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timezone
from utils.logger import logger
def _utcnow_iso() -> str:
"""
Return a naive UTC ISO timestamp.
datetime.utcnow() is deprecated in Python 3.12+; this
helper preserves the historical naive-UTC string format
(no '+00:00' suffix) used throughout the schema so
existing rows compare correctly.
"""
return datetime.now(timezone.utc).replace(
tzinfo=None
).isoformat()
class MetadataDB:
"""
Metadata storage layer
for crawler state tracking.
Stores:
- crawled pages
- crawl status
- markdown outputs
- assets
- hashes
- timestamps
- incremental update info
"""
def __init__(
self,
db_path="data/metadata.db"
):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(
parents=True,
exist_ok=True
)
self.conn = sqlite3.connect(
self.db_path
)
self.conn.row_factory = sqlite3.Row
# depth > 0 means we're inside a batched transaction
# and per-row commits should be suppressed
self._tx_depth = 0
self.initialize()
def initialize(self):
"""
Create required tables.
"""
cursor = self.conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA temp_store=MEMORY")
cursor.execute("PRAGMA cache_size=-65536")
# Pages table
cursor.execute("""
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE,
title TEXT,
markdown_path TEXT,
status TEXT,
status_code INTEGER,
content_hash TEXT,
word_count INTEGER,
markdown_length INTEGER,
crawled_at TEXT,
updated_at TEXT,
parent_url TEXT,
depth INTEGER,
metadata_json TEXT
)
""")
# Assets table
cursor.execute("""
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_url TEXT,
asset_url TEXT,
local_path TEXT,
downloaded_at TEXT
)
""")
# Site graph edges
cursor.execute("""
CREATE TABLE IF NOT EXISTS page_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_url TEXT NOT NULL,
target_url TEXT NOT NULL,
link_type TEXT DEFAULT 'internal',
discovered_at TEXT,
UNIQUE(source_url, target_url)
)
""")
# Discovery provenance
cursor.execute("""
CREATE TABLE IF NOT EXISTS discovery_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
discovery_type TEXT,
source_url TEXT,
discovered_at TEXT
)
""")
# Crawl queue state
cursor.execute("""
CREATE TABLE IF NOT EXISTS crawl_queue (
url TEXT PRIMARY KEY,
status TEXT NOT NULL,
priority INTEGER DEFAULT 0,
depth INTEGER DEFAULT 0,
parent_url TEXT,
discovered_at TEXT,
started_at TEXT,
finished_at TEXT,
attempts INTEGER DEFAULT 0,
last_error TEXT
)
""")
# Failed pages
cursor.execute("""
CREATE TABLE IF NOT EXISTS failures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT,
error_message TEXT,
failed_at TEXT
)
""")
self.conn.commit()
# Add the indexes that drive the hot path. IF NOT
# EXISTS makes this safe on existing databases.
cursor.execute(
"CREATE INDEX IF NOT EXISTS "
"idx_crawl_queue_pick "
"ON crawl_queue(status, priority DESC, depth, "
"discovered_at)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS "
"idx_page_links_source "
"ON page_links(source_url)"
)
# Conditional-GET columns. SQLite has no "ADD COLUMN
# IF NOT EXISTS" so swallow the duplicate-column error
# when running against an existing database.
for column in ("etag TEXT", "last_modified TEXT"):
try:
cursor.execute(
f"ALTER TABLE pages ADD COLUMN {column}"
)
except sqlite3.OperationalError as e:
if "duplicate column" not in str(e).lower():
raise
self.conn.commit()
logger.info(
"Metadata database initialized."
)
@contextmanager
def transaction(self):
"""
Batch many writes into one commit (one fsync).
Nested usage is supported: only the outermost block
actually issues BEGIN/COMMIT. This lets save_* helpers
stay safe to call individually while also being cheap
when wrapped in a per-page transaction.
"""
if self._tx_depth == 0:
self.conn.execute("BEGIN")
self._tx_depth += 1
try:
yield
except Exception:
self._tx_depth -= 1
if self._tx_depth == 0:
self.conn.rollback()
raise
else:
self._tx_depth -= 1
if self._tx_depth == 0:
self.conn.commit()
def _commit(self):
"""
Commit unless we are inside a batched transaction.
"""
if self._tx_depth == 0:
self.conn.commit()
def save_page(
self,
url,
title="",
markdown_path="",
status="success",
status_code=200,
content_hash="",
word_count=0,
markdown_length=0,
parent_url=None,
depth=0,
metadata=None,
etag=None,
last_modified=None,
):
"""
Insert or update page metadata.
"""
cursor = self.conn.cursor()
now = _utcnow_iso()
metadata_json = json.dumps(
metadata or {},
ensure_ascii=False
)
cursor.execute(
"""
INSERT INTO pages (
url,
title,
markdown_path,
status,
status_code,
content_hash,
word_count,
markdown_length,
crawled_at,
updated_at,
parent_url,
depth,
metadata_json,
etag,
last_modified
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url)
DO UPDATE SET
title=excluded.title,
markdown_path=excluded.markdown_path,
status=excluded.status,
status_code=excluded.status_code,
content_hash=excluded.content_hash,
word_count=excluded.word_count,
markdown_length=excluded.markdown_length,
updated_at=excluded.updated_at,
metadata_json=excluded.metadata_json,
etag=COALESCE(excluded.etag, pages.etag),
last_modified=COALESCE(
excluded.last_modified,
pages.last_modified
)
""",
(
url,
title,
markdown_path,
status,
status_code,
content_hash,
word_count,
markdown_length,
now,
now,
parent_url,
depth,
metadata_json,
etag,
last_modified,
),
)
self._commit()
logger.debug(
f"Saved page metadata: {url}"
)
def get_cached_headers(self, url):
"""
Return (etag, last_modified) for an already-crawled
URL, or (None, None) if unknown.
"""
cursor = self.conn.cursor()
cursor.execute(
"SELECT etag, last_modified FROM pages WHERE url=?",
(url,),
)
row = cursor.fetchone()
if not row:
return None, None
return row["etag"], row["last_modified"]
def save_asset(
self,
page_url,
asset_url,
local_path
):
"""
Save asset metadata.
"""
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO assets (
page_url,
asset_url,
local_path,
downloaded_at
)
VALUES (?, ?, ?, ?)
""", (
page_url,
asset_url,
local_path,
_utcnow_iso()
))
self._commit()
def save_page_link(
self,
source_url,
target_url,
link_type="internal"
):
cursor = self.conn.cursor()
cursor.execute("""
INSERT OR IGNORE INTO page_links (
source_url,
target_url,
link_type,
discovered_at
)
VALUES (?, ?, ?, ?)
""", (
source_url,
target_url,
link_type,
_utcnow_iso()
))
self._commit()
def save_discovery_source(
self,
url,
discovery_type,
source_url=None
):
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO discovery_sources (
url,
discovery_type,
source_url,
discovered_at
)
VALUES (?, ?, ?, ?)
""", (
url,
discovery_type,
source_url,
_utcnow_iso()
))
self._commit()
def get_outgoing_links(
self,
source_url
):
cursor = self.conn.cursor()
cursor.execute("""
SELECT target_url
FROM page_links
WHERE source_url=?
""", (source_url,))
return [
row["target_url"]
for row in cursor.fetchall()
]
def export_site_graph(
self,
output_path
):
cursor = self.conn.cursor()
cursor.execute("""
SELECT
source_url,
target_url,
link_type
FROM page_links
""")
rows = cursor.fetchall()
graph = [
dict(row)
for row in rows
]
with open(
output_path,
"w",
encoding="utf-8"
) as f:
json.dump(
graph,
f,
ensure_ascii=False,
indent=2
)
logger.info(
f"Exported site graph: {output_path}"
)
def save_failure(
self,
url,
error_message
):
"""
Save failed crawl.
"""
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO failures (
url,
error_message,
failed_at
)
VALUES (?, ?, ?)
""", (
url,
error_message,
_utcnow_iso()
))
self._commit()
logger.warning(
f"Saved failure: {url}"
)
def enqueue_url(
self,
url,
parent_url=None,
depth=0,
priority=0
):
"""
Save crawl queue item.
"""
cursor = self.conn.cursor()
cursor.execute("""
INSERT OR IGNORE INTO crawl_queue (
url,
status,
priority,
depth,
parent_url,
discovered_at,
attempts
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
url,
"pending",
priority,
depth,
parent_url,
_utcnow_iso(),
0
))
self._commit()
def update_queue_status(
self,
url,
status
):
"""
Update queue item status.
"""
cursor = self.conn.cursor()
cursor.execute("""
UPDATE crawl_queue
SET status=?
WHERE url=?
""", (
status,
url
))
self._commit()
def page_exists(
self,
url
):
"""
Check if page already crawled.
"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT 1
FROM pages
WHERE url=?
LIMIT 1
""", (url,))
row = cursor.fetchone()
return row is not None
def get_page(
self,
url
):
"""
Retrieve page metadata.
"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT *
FROM pages
WHERE url=?
""", (url,))
row = cursor.fetchone()
if not row:
return None
return dict(row)
def get_all_pages(self):
"""
Get all crawled pages.
"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT *
FROM pages
ORDER BY crawled_at DESC
""")
rows = cursor.fetchall()
return [
dict(row)
for row in rows
]
def get_failed_pages(self):
"""
Get all failures.
"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT *
FROM failures
ORDER BY failed_at DESC
""")
rows = cursor.fetchall()
return [
dict(row)
for row in rows
]
def get_pending_queue(self):
"""
Get pending URLs.
"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT *
FROM crawl_queue
WHERE status='pending'
""")
rows = cursor.fetchall()
return [
dict(row)
for row in rows
]
def get_next_pending_url(self):
cursor = self.conn.cursor()
cursor.execute("""
SELECT *
FROM crawl_queue
WHERE status IN ('pending', 'retry')
ORDER BY priority DESC, depth ASC, discovered_at ASC
LIMIT 1
""")
row = cursor.fetchone()
return dict(row) if row else None
def mark_processing(self, url):
cursor = self.conn.cursor()
cursor.execute("""
UPDATE crawl_queue
SET
status='processing',
started_at=?,
attempts=attempts + 1
WHERE url=?
""", (
_utcnow_iso(),
url
))
self._commit()
def mark_done(self, url):
cursor = self.conn.cursor()
cursor.execute("""
UPDATE crawl_queue
SET
status='done',
finished_at=?
WHERE url=?
""", (
_utcnow_iso(),
url
))
self._commit()
def mark_failed(self, url, error_message, max_attempts=3):
cursor = self.conn.cursor()
cursor.execute("""
SELECT attempts
FROM crawl_queue
WHERE url=?
""", (url,))
row = cursor.fetchone()
attempts = row["attempts"] if row else 0
next_status = "failed" if attempts >= max_attempts else "retry"
cursor.execute("""
UPDATE crawl_queue
SET
status=?,
last_error=?,
finished_at=?
WHERE url=?
""", (
next_status,
error_message,
_utcnow_iso(),
url
))
cursor.execute("""
INSERT INTO failures (
url,
error_message,
failed_at
)
VALUES (?, ?, ?)
""", (
url,
error_message,
_utcnow_iso()
))
self._commit()
logger.warning(
f"Marked URL as {next_status}: {url}"
)
def mark_permanently_failed(self, url, error_message):
"""
Skip the retry ramp and mark the URL as terminally
failed. Used for hard 4xx (e.g. 404) where retrying
the same URL will produce the same response.
"""
cursor = self.conn.cursor()
cursor.execute(
"""
UPDATE crawl_queue
SET
status='failed',
last_error=?,
finished_at=?
WHERE url=?
""",
(
error_message,
_utcnow_iso(),
url,
),
)
cursor.execute(
"""
INSERT INTO failures (
url,
error_message,
failed_at
)
VALUES (?, ?, ?)
""",
(url, error_message, _utcnow_iso()),
)
self._commit()
logger.warning(
f"Marked URL permanently failed: {url} ({error_message})"
)
def reset_stuck_processing(self):
cursor = self.conn.cursor()
cursor.execute("""
UPDATE crawl_queue
SET status='retry'
WHERE status='processing'
""")
affected = cursor.rowcount
self._commit()
if affected:
logger.warning(
f"Recovered {affected} stuck processing URLs."
)
def is_done(self, url):
cursor = self.conn.cursor()
cursor.execute("""
SELECT 1
FROM crawl_queue
WHERE url=? AND status='done'
LIMIT 1
""", (url,))
return cursor.fetchone() is not None
def is_known(self, url):
cursor = self.conn.cursor()
cursor.execute("""
SELECT 1
FROM crawl_queue
WHERE url=?
LIMIT 1
""", (url,))
return cursor.fetchone() is not None
def queue_stats(self):
cursor = self.conn.cursor()
cursor.execute("""
SELECT status, COUNT(*) AS count
FROM crawl_queue
GROUP BY status
""")
return {
row["status"]: row["count"]
for row in cursor.fetchall()
}
def delete_page(
self,
url
):
"""
Remove page metadata.
"""
cursor = self.conn.cursor()
cursor.execute("""
DELETE FROM pages
WHERE url=?
""", (url,))
self._commit()
def clear_failures(self):
"""
Clear failure table.
"""
cursor = self.conn.cursor()
cursor.execute("""
DELETE FROM failures
""")
self._commit()
def export_pages_json(
self,
output_path
):
"""
Export all pages metadata.
"""
pages = self.get_all_pages()
with open(
output_path,
"w",
encoding="utf-8"
) as f:
json.dump(
pages,
f,
ensure_ascii=False,
indent=2
)
logger.info(
f"Exported metadata JSON: "
f"{output_path}"
)
def close(self):
"""
Close database connection.
"""
self.conn.close()
logger.info(
"Metadata database closed."
)