Python爬虫实战:批量获取企业工商信息并导出Excel

发布时间:2026/10/9 18:51:44
Python爬虫实战:批量获取企业工商信息并导出Excel 上个月一位做销售的朋友抱着一份 Excel 来找我里面是四十多家企业名称老板让他查清每家的工商登记信息把法定代表人、注册资本、成立日期、统一社会信用代码补齐。他手动打开企业信息查询网站一家一家搜、一条一条复制忙了一上午才完成三分之一。我扫了眼这份表说这事可以用 Python 爬虫干完。于是就有了这篇文章——输入公司名称脚本自动爬取企查查这类企业信息查询网站中的公开公司信息最终整理成一份干净整齐的 Excel。适合被这类重复工作困扰、手头有二三十家以上公司要查的读者也适合想学习爬虫基础套路的朋友参考。1. 需求拆解与合规边界先想清楚“要什么、能不能做”1.1 需求拆解一个输入N个结构化字段输出这个项目表面上只有一句话但稍微拆一下就能发现它实际上是两段式流程先搜索匹配公司再进入详情页抓取完整字段。搜索阶段处理的是“用户输入的公司名如何对应到目标站点里的具体公司”详情阶段处理的是“如何从页面或接口里把目标字段稳定地提取出来”。我建议把产出字段在动手前先列一张清单不要边写边加。以这个项目为例我一开始锁定了下面这些字段公司名称统一社会信用代码法定代表人注册资本成立日期经营状态注册地址字段少的时候感受不到规划的价值一旦要加“参保人数”“经营范围”“股东信息”这些复杂字段没有清单就会在解析代码里越改越乱。把字段清单当作接口协议来对待后面所有环节都会顺畅很多。1.2 技术选型与整体架构技术栈我选的是Python requests BeautifulSoup pandas。没有上 Scrapy因为这类任务的量级通常只有几十到几百家公司requests 足够用Scrapy 的异步框架和中间件机制对这个场景来说学习成本偏高有点杀鸡用牛刀。为什么不直接上 Selenium很多人一看到网站有反爬迹象第一反应就是“用浏览器自动化”。但 Selenium 会启动完整浏览器速度慢、内存占用高而且很多动态渲染的页面其实底层有接口可以直接返回 JSON。先抓包看一眼确认数据到底是 HTML 直接渲染还是接口异步加载再决定方案往往能省掉大量麻烦。这个判断方法我放在第 2 节详细说。整体架构在脑子里过一遍就是读入公司名称列表 → 构造搜索请求 → 解析搜索列表 → 取候选公司详情页链接 → 请求详情页 → 提取字段 → 清洗 → 存储 → 输出 Excel。每一环都做成独立函数后续出问题只改对应位置不用动主流程。1.3 合规基线公开可见不等于可以随便爬写爬虫之前必须先想清楚边界这不是套话是保护自己不踩坑的前提。这个项目的目标是获取正常浏览就能看到的公开工商信息而且只用于学习或正当业务背景调查。即便如此也要遵守几件事遵守目标网站的服务条款不针对登录后才可见的数据做自动化采集。不采集法定代表人手机号等个人敏感信息这是红线。低频小规模采集模拟正常用户浏览节奏不对服务器造成压力。遇到登录墙、验证码拦截就停下来人工处理不要试图绕过风控机制。如果是大规模商用数据需求应该使用官方数据产品服务而不是自己写批量爬虫硬扛。下文的全部代码演示都使用占位地址实际运行时请你以自己调研确认的目标站点信息为准。技术思路通用但任何自动化操作都要以合规为前提。2. 目标站点的数据链路与字段映射从搜索到详情页到底发生了什么2.1 先判断页面数据从哪来HTML直出还是接口JSON写爬虫第一步不是急着找 API而是先把数据链路摸清楚。打开浏览器的无痕窗口进入开发者工具的 Network 面板在页面上搜索一家公司观察网络请求列表里发生了什么。如果发现一个返回 JSON 的 XHR 请求里面直接带了公司列表那就恭喜了这类接口返回的数据结构通常很规整解析成本最低。如果没看到 XHR而是页面本身直接刷新了 HTML那大概率是服务端渲染只能老老实实解析 HTML。还有一种常见情况是页面 HTML 的script标签里内嵌了一段 JSON 数据这种情况比硬解析 HTML 更可靠可以先用正则或 JSON 提取逻辑把它捞出来。我在这个项目里是用接口 JSON 的方式来演示搜索逻辑用 HTML 方式来演示详情解析。为什么这么设计因为真实场景下企业信息平台经常是“搜索走接口、详情走服务端渲染”的混合模式单一假设会导致代码在做下一个站点时立刻失效。2.2 搜索参数与详情链接的拼接逻辑搜索阶段的核心是搞清楚请求要带哪些参数。通常至少有一个关键词参数加上页码、每页条数、排序方式之类。下面是用requests发起搜索请求并解析结果的通用代码模式import requests import json # 注意这里的地址是演示用占位符真实项目请以目标站点实际接口为准 SEARCH_API https://www.example-corp-info.com/api/search HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.example-corp-info.com/, } def search_company(session, keyword, page1): params { keyword: keyword, page: page, size: 10, } resp session.get(SEARCH_API, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() # 不同站点的返回结构不同这里只演示最常见的层级 data resp.json() return data.get(data, {}).get(list, []) session requests.Session() results search_company(session, 某新能源科技有限公司) print(json.dumps(results[:1], ensure_asciiFalse, indent2))搜索接口返回的列表项里通常已经带上了公司详情页的链接。这里有个容易踩的坑很多平台返回的链接是相对路径比如/company/123456.html直接向这个地址发请求会拿到 404。正确做法是用urljoin拼接成绝对地址from urllib.parse import urljoin BASE_URL https://www.example-corp-info.com detail_url urljoin(BASE_URL, item.get(detailUrl, ))这一步看似简单但我在早期项目里吃过亏拼接出来的地址少一个域名前缀排查了大半天最后发现是urljoin的用法问题。建议所有相对链接统一走这个方法不要手动去拼字符。2.3 字段映射表页面显示名与内部字段名的对应关系详情页解析前先做一张字段映射表把页面上的中文标签和代码里的字段名对应起来。这样写解析函数时思路会非常清晰后面想调整字段也只需要改一张表。页面显示字段内部字段名示例值清洗说明公司名称company_name某新能源科技有限公司去除首尾空白统一社会信用代码credit_code91440101MA5EXAMPLE保持原样法定代表人legal_representative王某某只保留姓名注册资本registered_capital1000万人民币统一转为“1000.0万元”成立日期established_date2020-01-15转为标准日期格式经营状态business_status存续保持原样注册地址registered_address某市某区某路某号去除换行符号这张表是项目的“数据协议”。后面做清洗和存储时所有字段名都从这张表引出避免代码里出现拼写不一致的问题。字段映射写的越细后面的坑越少。3. 核心代码实现输入公司名输出结构化表格3.1 请求会话与请求头配置写爬虫的人都知道要带 User-Agent但很多人只带一个 UA其他请求头全裸奔。真实场景下目标站点的风控会检查请求头的完整性包括 Accept、Accept-Language、Referer、Cookie 等字段。我习惯用requests.Session()来管理请求因为 Session 会自动保存服务端返回的 Cookie后续的详情页请求会带上这些 Cookie行为更像一个正常的浏览器会话。看下完整的请求会话初始化代码import requests import random import time def build_session(): session requests.Session() session.headers.update( { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9, image/webp,image/apng,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, } ) return session如果是搜索接口返回 JSON 的情况可以把Accept调整成application/json。两个场景的请求头稍有不同我一般会用requests.Request的构造方式来区分或者直接为不同请求传不同的headers参数。这个细节不值得过度纠结但确实会影响请求成功率。3.2 搜索阶段把公司名称变成候选列表搜索逻辑的核心是解析返回的 JSON 结构。不同平台的字段名差异很大有的叫dataList有的叫records有的藏在result里。我的建议是先把真实返回打印出来看一眼再写解析代码。下面的示例是基于常见的data.list结构def get_candidate(session, company_name): params { keyword: company_name, page: 1, size: 5, } try: resp session.get(SEARCH_API, paramsparams, timeout10) resp.raise_for_status() payload resp.json() items payload.get(data, {}).get(list, []) candidates [] for item in items: candidates.append( { name: item.get(name, ), credit_code: item.get(creditCode, ), detail_url: urljoin(BASE_URL, item.get(detailUrl, )), } ) return candidates except Exception as exc: print(f[搜索失败] {company_name}: {exc}) return []需要注意的是“搜索关键词不精确”的问题。用户输入的可能是简称比如“某新能源”但目标公司全称是“某新能源科技有限公司”。搜索结果可能有多家公司取第一个不一定对。我在第 6 节会专门讲同名错配的应对方案这里先给一个原则搜索阶段不要只取第一名要把候选列表都保存下来之后再做匹配确认。3.3 详情页解析从 HTML 里稳定提取字段详情页可能是服务端渲染的 HTML字段标签和值通常放在相邻的表格节点里。用 BeautifulSoup 解析时最稳妥的方式不是硬写选择器而是基于“标签文本定位”先找到包含特定文本的节点再从它的父节点出发找到值所在的兄弟节点。看示例代码from bs4 import BeautifulSoup FIELD_MAP { 公司名称: company_name, 统一社会信用代码: credit_code, 法定代表人: legal_representative, 注册资本: registered_capital, 成立日期: established_date, 经营状态: business_status, 注册地址: registered_address, } def parse_detail(soup): result {} for label_text, field_name in FIELD_MAP.items(): label_node soup.find(stringlabel_text) if not label_node: result[field_name] None continue # 找到标签所在的父单元格再去下一个兄弟单元格取值 parent label_node.find_parent(td) if not parent: result[field_name] None continue sibling parent.find_next_sibling(td) if not sibling: result[field_name] None continue value sibling.get_text(stripTrue) result[field_name] value if value else None return result这种“找文本、再找兄弟节点”的写法比硬编码 CSS 选择器更抗页面改版。因为企业信息平台的页面结构经常微调但“行标签挨着值”的基本模式不容易变。当然如果某个字段是独立区块而不是表格需要单独写解析规则这是正常情况不必追求一个函数处理所有字段。3.4 主流程串联第一版先让它跑起来主流程就是把前面的函数串起来输入公司名列表输出字典列表。考虑到网络请求的不确定性我会在每家公司之间加随机延时。第一版代码宁可简陋也要先跑通确认能拿到数据后再谈优化。def crawl_company(session, company_name): candidates get_candidate(session, company_name) if not candidates: return {query_name: company_name, error: 未搜到候选} # 第一版简化逻辑先取第一个候选后续再优化匹配策略 info candidates[0] detail_resp session.get(info[detail_url], timeout10) detail_resp.raise_for_status() soup BeautifulSoup(detail_resp.text, html.parser) detail parse_detail(soup) detail[query_name] company_name detail[candidate_name] info[name] return detail def crawl_batch(session, company_names): rows [] for idx, name in enumerate(company_names, 1): print(f正在处理 {idx}/{len(company_names)}: {name}) row crawl_company(session, name) rows.append(row) time.sleep(random.uniform(1.5, 3.5)) return rows company_list [某新能源科技有限公司, 某信息科技有限公司] results crawl_batch(build_session(), company_list) print(results)第一版跑通之后再回来看数据你会发现很多字段需要清洗很多边角情况需要处理。这就进入了下一阶段稳定性与容错。4. 稳定性设计限速、重试、断点续跑让爬虫能跑完4.1 请求频率控制礼貌爬取的节奏很多人盯着“反爬绕过”想却忽略了最核心的问题大多数失败是因为你自己请求太快而不是对方风控多严。正常用户浏览一家公司详情页怎么看也要好几秒。你用脚本每 0.5 秒打一次接口跟刷数据没有区别被拦截是早晚的事。我的做法是给每个公司请求之间加上随机延时让请求节奏接近人类行为。随机延时比固定延时更灵活也能避免“每 3 秒一次”这种机器特征过于明显的规律time.sleep(random.uniform(1.5, 3.5))这个区间不算保守也不算激进。如果发现请求还是频繁失败就把区间拉大到3到6秒哪怕 100 家公司也就多等几分钟总比被封掉后重跑一遍强。4.2 异常重试不是所有错误都值得重试网络请求的失败原因五花八门超时、连接重置、临时限流、服务端 5xx……重试很有必要但要注意重试策略。404、403 这类状态码重试多少次都不会成功应当直接放弃而 超时 和 5xx 属于暂时性故障可以重试几次。一个常见的重试函数长这样def fetch_url(session, url, retries3, timeout10): for attempt in range(1, retries 1): try: resp session.get(url, timeouttimeout) if resp.status_code 200: return resp if resp.status_code in (403, 404): print(f不可重试的状态码: {resp.status_code}, 放弃: {url}) return None print(f状态码 {resp.status_code}, 重试 {attempt}/{retries}) except requests.RequestException as exc: print(f请求异常 {exc}, 重试 {attempt}/{retries}) time.sleep(2 * attempt) return None这里的退避策略是简单版每次重试后等待时间翻倍最多 3 次。如果你希望更精细可以用指数退避加抖动但对这个量级的项目3 次线性退避完全够用。4.3 断点续跑中断后不从头再来批量任务最烦的事情就是跑到第 80 家网络断了重启后又要从第 1 家开始。解决思路很简单每次成功处理完一家公司就把它的名字写入一个“已完成”文件。下次启动前加载这个文件跳过已完成的公司。import os DONE_FILE done.txt def load_done(): if not os.path.exists(DONE_FILE): return set() with open(DONE_FILE, encodingutf-8) as f: return {line.strip() for line in f if line.strip()} def mark_done(name): with open(DONE_FILE, a, encodingutf-8) as f: f.write(name \n) done_set load_done() for name in company_list: if name in done_set: print(f跳过已完成: {name}) continue # ...处理逻辑 mark_done(name)这个做法简单有效。如果数据已经存 Excel 了也可以把“完成”判断改成读 Excel 里已有的公司名逻辑类似只是数据源不同。4.4 日志与进度让脚本状态可见散落的print在调试时够用跑批量任务时还是要引入logging至少把错误信息和成功信息区分开方便排查问题。日志级别上我习惯把“成功处理”放在 INFO把“异常”放在 WARNING 或 ERROR。再加一个进度统计会舒服很多。用一个Counter记录成功和失败数量跑完打印汇总一眼就知道整体结果怎么样。这个统计对后面判断“是否需要换策略”很有价值比如 50 家公司里失败了 30 家说明不是偶发问题要去查代码或数据源。5. 数据清洗与存储沉淀成可用的企业名单5.1 字段清洗把“看着对”的数据变成“用着对”的数据爬下来的数据不会那么干净。注册资本可能是“1000万人民币”“1000万元”“1000.00万”等多种写法成立日期可能是“2020年1月15日”而不是标准日期注册地址里可能夹着换行和全角空格。这些都需要清洗。注册资本清洗是一个典型场景。我一般会把“万人民币”“万元”“万”这些单位统一处理成“万元”并提取数字部分import re def clean_capital(text): if not text: return None text str(text).strip() match re.search(r([\d.])\s*(万|万元|亿)?, text) if not match: return text num float(match.group(1)) unit match.group(2) or if 亿 in unit: num num * 10000 return f{num:.2f}万元日期清洗可以用dateutil.parser自动识别大部分中文日期格式也可以根据字段实际情况手写strptime。这里提醒一句同一字段的格式越统一后续在 Excel 里排序和筛选就越方便。5.2 存储到 Excelpandas 一行搞定清洗完的数据用pandas.DataFrame整理后导出 Excel是最直观的输出方式。Excel 也是那个销售朋友唯一会用的工具交付起来几乎没有门槛。import pandas as pd df pd.DataFrame(results, columns[ query_name, candidate_name, company_name, credit_code, legal_representative, registered_capital, established_date, business_status, registered_address, error ]) df df.drop_duplicates(subset[credit_code], keepfirst) df.to_excel(company_info.xlsx, indexFalse)注意两个细节一是导出前用drop_duplicates按信用代码去重防止同一个公司被重复抓取二是确保安装了openpyxl否则to_excel会报缺少引擎的错。5.3 存储到 SQLite适合长期积累和查询如果这个项目不是一次性的而是每个月都要跑一轮我建议把结果落到 SQLite。好处是数据能持续积累后续可以用 SQL 去重、按成立日期排序、按经营状态筛选不用每次重新从 Excel 里过滤。建表和插入的示例import sqlite3 conn sqlite3.connect(company.db) conn.execute( CREATE TABLE IF NOT EXISTS company_info ( credit_code TEXT PRIMARY KEY, company_name TEXT, legal_representative TEXT, registered_capital TEXT, established_date TEXT, business_status TEXT, registered_address TEXT, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) for row in results: try: conn.execute( INSERT OR IGNORE INTO company_info (credit_code, company_name, legal_representative, registered_capital, established_date, business_status, registered_address) VALUES (?, ?, ?, ?, ?, ?, ?), ( row.get(credit_code), row.get(company_name), row.get(legal_representative), row.get(registered_capital), row.get(established_date), row.get(business_status), row.get(registered_address), ), ) except Exception as exc: print(f写入失败: {exc}) conn.commit() conn.close()INSERT OR IGNORE配合信用代码主键天然实现了“已存在的记录不重复插入”下次再跑同一批公司新数据不会覆盖旧记录。想要最新数据时用UPDATE语法单独更新变更字段就行。6. 实测踩坑与优化清单几个最典型的翻车现场6.1 编码问题中文乱码的元凶第一个最典型的问题是中文乱码。requests的response.text会自动从响应头推断编码但推断不一定准经常是页面实际GBK它却按UTF-8解码结果公司名称变成一堆乱码。遇到这种情况显式指定编码是最好的办法resp session.get(url, timeout10) resp.encoding resp.apparent_encoding # 或者直接指定 utf-8 / gbk如果用resp.apparent_encoding还是不对就用resp.content.decode(utf-8)或resp.content.decode(gbk)手动解码。这个坑几乎每个人都会踩一次但解决也就一行代码的事。建议在封装请求函数时就把编码策略固定下来不要散落各地。6.2 403 与验证码请求头不全和工作时间批量跑的时候遇到 403第一反应不要是“上代理绕 IP”而是检查三件事请求头是否齐全、请求频率是否过快、是否真的要登录才能访问。我见过不少 403 只是缺了Referer补上之后立刻正常。如果还不行页面弹出了验证码我的建议是停一下不要硬碰。把任务暂停过一段时间再用会话重试或者人工访问一次验证码页面让该 IP 的信任度恢复。拿代理池无限换 IP 去对抗风控既不优雅也容易得罪目标站点正经项目不推荐这么干。6.3 公司名称同名与关键词错配这是数据质量上最隐蔽的坑。输入“某华工程有限公司”搜索返回了三个相同名称但注册在不同省份的公司。我第一版代码直接取第一个结果抓到的是异地的同名公司等于整条数据都是错的。后来加了匹配策略如果输入清单里自带地区优先匹配注册地址含该地区的公司。如果有统一社会信用代码用代码前几位做地区码匹配。都没有时把搜索结果中名称完全一致的候选全部列出来由人工在输出表里标记确认。这个问题的核心不是代码写不好而是数据本身有多义性。爬虫只负责把候选抓全匹配决策不能盲目自动拍板。6.4 字段缺失与页面结构变化老企业的详情页可能缺失“注册资本”或“成立日期”页面改版时原来的节点位置会变。我用“标签文本定位”的方法就是为了尽量扛住改版但即使这样也会有字段找不到的情况。正确做法是字段缺失时返回None而不是抛异常中断整个循环。前面parse_detail里的写法就是为了保证单家公司失败不影响整批任务。跑完优先检查None比较多的字段往往能反推出页面结构变化或字段映射表需要更新。数据质量检查永远要排在“多抓几个字段”的前面抓到了错的还不如不抓。最后再分享一点个人体会这个项目做了几轮以后我的体感是爬虫本身只占三分之一的精力剩下三分之二都花在了数据确认上。自动化能帮你把“复制粘贴”这种纯体力活干掉但干完以后你一定要打开 Excel 抽几行人工核对确认字段映射、清洗逻辑、同名选择策略都没问题再面对领导或客户交付这份数据。第一次跑批量任务之前先拿 5 家公司做试跑确认输出没问题再放量这个习惯能帮你省掉无数返工。工具的价值是让重复工作变快而不是替代你对数据负责——这句话用在所有爬虫项目上都成立。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询