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>
275 lines
4.9 KiB
Python
275 lines
4.9 KiB
Python
import asyncio
|
|
from functools import wraps
|
|
import random
|
|
|
|
from tenacity import (
|
|
retry,
|
|
stop_after_attempt,
|
|
wait_exponential,
|
|
retry_if_exception_type,
|
|
before_sleep_log
|
|
)
|
|
|
|
from utils.logger import logger
|
|
|
|
|
|
class RetryConfig:
|
|
"""
|
|
Global retry configuration.
|
|
"""
|
|
|
|
DEFAULT_ATTEMPTS = 3
|
|
|
|
DEFAULT_MIN_WAIT = 1
|
|
|
|
DEFAULT_MAX_WAIT = 10
|
|
|
|
DEFAULT_JITTER = 0.5
|
|
|
|
|
|
def retry_sync(
|
|
attempts=RetryConfig.DEFAULT_ATTEMPTS,
|
|
min_wait=RetryConfig.DEFAULT_MIN_WAIT,
|
|
max_wait=RetryConfig.DEFAULT_MAX_WAIT,
|
|
exceptions=(Exception,)
|
|
):
|
|
"""
|
|
Retry decorator for sync functions.
|
|
|
|
Example:
|
|
|
|
@retry_sync()
|
|
def task():
|
|
...
|
|
"""
|
|
|
|
return retry(
|
|
stop=stop_after_attempt(attempts),
|
|
|
|
wait=wait_exponential(
|
|
multiplier=1,
|
|
min=min_wait,
|
|
max=max_wait
|
|
),
|
|
|
|
retry=retry_if_exception_type(
|
|
exceptions
|
|
),
|
|
|
|
before_sleep=before_sleep_log(
|
|
logger,
|
|
"WARNING"
|
|
),
|
|
|
|
reraise=True
|
|
)
|
|
|
|
|
|
def retry_async(
|
|
attempts=RetryConfig.DEFAULT_ATTEMPTS,
|
|
min_wait=RetryConfig.DEFAULT_MIN_WAIT,
|
|
max_wait=RetryConfig.DEFAULT_MAX_WAIT,
|
|
jitter=RetryConfig.DEFAULT_JITTER,
|
|
exceptions=(Exception,)
|
|
):
|
|
"""
|
|
Retry decorator for async functions.
|
|
|
|
Example:
|
|
|
|
@retry_async()
|
|
async def fetch():
|
|
...
|
|
"""
|
|
|
|
def decorator(func):
|
|
|
|
@wraps(func)
|
|
async def wrapper(*args, **kwargs):
|
|
|
|
last_exception = None
|
|
|
|
for attempt in range(
|
|
1,
|
|
attempts + 1
|
|
):
|
|
|
|
try:
|
|
|
|
logger.debug(
|
|
f"Attempt {attempt}/"
|
|
f"{attempts}: "
|
|
f"{func.__name__}"
|
|
)
|
|
|
|
return await func(
|
|
*args,
|
|
**kwargs
|
|
)
|
|
|
|
except exceptions as e:
|
|
|
|
last_exception = e
|
|
|
|
logger.warning(
|
|
f"Retry failed "
|
|
f"{attempt}/{attempts} "
|
|
f"for {func.__name__}: {e}"
|
|
)
|
|
|
|
if attempt >= attempts:
|
|
break
|
|
|
|
base_sleep = min(
|
|
min_wait * (2 ** (attempt - 1)),
|
|
max_wait
|
|
)
|
|
|
|
sleep_time = base_sleep + random.uniform(
|
|
0,
|
|
jitter
|
|
)
|
|
|
|
logger.info(
|
|
f"Sleeping "
|
|
f"{sleep_time:.2f}s before retry"
|
|
)
|
|
|
|
await asyncio.sleep(
|
|
sleep_time
|
|
)
|
|
|
|
logger.error(
|
|
f"Max retries exceeded "
|
|
f"for {func.__name__}"
|
|
)
|
|
|
|
raise last_exception
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
async def retry_operation(
|
|
coro,
|
|
attempts=3,
|
|
delay=1,
|
|
exceptions=(Exception,)
|
|
):
|
|
"""
|
|
Retry arbitrary async coroutine.
|
|
|
|
Example:
|
|
|
|
result = await retry_operation(
|
|
fetch_page(url)
|
|
)
|
|
"""
|
|
|
|
last_exception = None
|
|
|
|
for attempt in range(
|
|
1,
|
|
attempts + 1
|
|
):
|
|
|
|
try:
|
|
|
|
logger.debug(
|
|
f"Retry operation "
|
|
f"attempt {attempt}/{attempts}"
|
|
)
|
|
|
|
return await coro
|
|
|
|
except exceptions as e:
|
|
|
|
last_exception = e
|
|
|
|
logger.warning(
|
|
f"Retry operation failed "
|
|
f"{attempt}/{attempts}: {e}"
|
|
)
|
|
|
|
if attempt >= attempts:
|
|
break
|
|
|
|
await asyncio.sleep(delay)
|
|
|
|
logger.error(
|
|
"Retry operation exceeded "
|
|
"max attempts."
|
|
)
|
|
|
|
raise last_exception
|
|
|
|
|
|
def safe_execute(
|
|
func,
|
|
*args,
|
|
default=None,
|
|
log_error=True,
|
|
**kwargs
|
|
):
|
|
"""
|
|
Execute function safely.
|
|
|
|
Returns default value if failed.
|
|
|
|
Example:
|
|
|
|
result = safe_execute(
|
|
parse_html,
|
|
html,
|
|
default=""
|
|
)
|
|
"""
|
|
|
|
try:
|
|
|
|
return func(
|
|
*args,
|
|
**kwargs
|
|
)
|
|
|
|
except Exception as e:
|
|
|
|
if log_error:
|
|
|
|
logger.exception(
|
|
f"Safe execute failed "
|
|
f"{func.__name__}: {e}"
|
|
)
|
|
|
|
return default
|
|
|
|
|
|
async def safe_execute_async(
|
|
func,
|
|
*args,
|
|
default=None,
|
|
log_error=True,
|
|
**kwargs
|
|
):
|
|
"""
|
|
Safe async execution.
|
|
"""
|
|
|
|
try:
|
|
|
|
return await func(
|
|
*args,
|
|
**kwargs
|
|
)
|
|
|
|
except Exception as e:
|
|
|
|
if log_error:
|
|
|
|
logger.exception(
|
|
f"Safe async execute failed "
|
|
f"{func.__name__}: {e}"
|
|
)
|
|
|
|
return default |