{"record":{"id":"a0eb6e27fdb6d10d","repo":"ZhuLinsen/daily_stock_analysis","slug":"akshare-e","errorCode":null,"errorMessage":"Akshare 可能被限流: {e}","messagePattern":"Akshare 可能被限流: (.+?)","errorType":"exception","errorClass":"RateLimitError","httpStatus":null,"severity":"error","filePath":"data_provider/akshare_fetcher.py","lineNumber":724,"sourceCode":"            \n            # 记录返回数据摘要\n            if df is not None and not df.empty:\n                logger.info(f\"[API返回] ak.fund_etf_hist_em 成功: 返回 {len(df)} 行数据, 耗时 {api_elapsed:.2f}s\")\n                logger.info(f\"[API返回] 列名: {list(df.columns)}\")\n                logger.info(f\"[API返回] 日期范围: {df['日期'].iloc[0]} ~ {df['日期'].iloc[-1]}\")\n                logger.debug(f\"[API返回] 最新3条数据:\\n{df.tail(3).to_string()}\")\n            else:\n                logger.warning(f\"[API返回] ak.fund_etf_hist_em 返回空数据, 耗时 {api_elapsed:.2f}s\")\n            \n            return df\n            \n        except Exception as e:\n            error_msg = str(e).lower()\n            \n            # 检测反爬封禁\n            if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']):\n                logger.warning(f\"检测到可能被封禁: {e}\")\n                raise RateLimitError(f\"Akshare 可能被限流: {e}\") from e\n            \n            raise DataFetchError(f\"Akshare 获取 ETF 数据失败: {e}\") from e\n    \n    def _fetch_us_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:\n        \"\"\"\n        获取美股历史数据\n        \n        数据来源：ak.stock_us_daily()（新浪财经接口）\n        \n        Args:\n            stock_code: 美股代码，如 'AMD', 'AAPL', 'TSLA'\n            start_date: 开始日期，格式 'YYYY-MM-DD'\n            end_date: 结束日期，格式 'YYYY-MM-DD'\n            \n        Returns:\n            美股历史数据 DataFrame\n        \"\"\"\n        import akshare as ak","sourceCodeStart":706,"sourceCodeEnd":742,"githubUrl":"https://github.com/ZhuLinsen/daily_stock_analysis/blob/5159bd72e8373d215492dff122acc9d389e219c9/data_provider/akshare_fetcher.py#L706-L742","documentation":"RateLimitError raised by the ETF fetch path (ak.fund_etf_hist_em) when the caught exception's lowercased message contains anti-scraping keywords ('banned', 'blocked', '频率', 'rate', '限制') — same heuristic as the stock channel. Any other failure mode on this path is re-raised as DataFetchError ('Akshare 获取 ETF 数据失败').","triggerScenarios":"Calling AkshareFetcher with an ETF code (detected by _is_etf_code) while Eastmoney throttles the fund_etf_hist_em endpoint: repeated ETF fetches in a short window produce an error containing a rate/ban keyword.","commonSituations":"Batch fetching a large ETF watchlist; CI or network smoke tests that hit the endpoint repeatedly; running multiple instances of the analyzer from the same IP concurrently.","solutions":["Wait for the throttle window to pass (minutes) before retrying ETF fetches.","Add spacing/jitter between ETF requests and cache results to avoid refetching.","Catch RateLimitError at the caller and fall back to another data source for ETF quotes if configured.","Reduce the symbol count per run or stagger schedules across instances.","If the error persists across long waits, verify the message isn't a false positive (a different error containing the word '限制')."],"exampleFix":"# before\nfor etf in ['510300', '510500', '512100']:\n    df = fetcher.fetch_stock_data(etf, start, end)\n\n# after: pace + cache\nimport time\ncache = {}\nfor etf in ['510300', '510500', '512100']:\n    if etf not in cache:\n        cache[etf] = fetcher.fetch_stock_data(etf, start, end)\n        time.sleep(3)","handlingStrategy":"retry","validationCode":null,"typeGuard":"from data_provider.exceptions import RateLimitError, DataFetchError\n\ndef is_etf_rate_limited(exc: Exception) -> bool:\n    return isinstance(exc, RateLimitError)\n\ndef is_etf_fetch_failed(exc: Exception) -> bool:\n    return isinstance(exc, DataFetchError) and '获取 ETF 数据失败' in str(exc)","tryCatchPattern":"try:\n    df = fetcher.fetch_stock_data(etf_code, start, end)\nexcept RateLimitError:\n    time.sleep(300)\n    df = fetcher.fetch_stock_data(etf_code, start, end)  # single retry after backoff\nexcept DataFetchError as e:\n    if '获取 ETF 数据失败' in str(e):\n        df = alternate_fetcher.fetch_stock_data(etf_code, start, end)\n    raise","preventionTips":["Space out ETF fetches and cache fund_etf_hist_em results per day.","Distinguish RateLimitError (backoff, retry) from DataFetchError (switch source) in handlers — this code path raises both.","Avoid parallel instances scraping the same Eastmoney endpoint from one IP."],"tags":["akshare","rate-limit","etf","anti-scraping","data-provider"],"backgroundTag":null,"analyzedSha":"5159bd72e8373d215492dff122acc9d389e219c9","analyzedAt":"2026-08-15T01:59:36.292Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}