1. 项目概述这不是一个“爬虫工具”而是一套可复用的APP数据采集工作流“Qimai_APPData”这个名字乍看像某个第三方封装库但实际接触过七麦数据网qimai.cn的从业者都清楚——它根本不是官方SDK也没有API接口授权。所谓“亲测免费”指的是一套由某开发者在2023年中后期整理发布的、基于网页结构逆向与请求模拟的Python脚本集合。我第一次看到这个标题是在一个垂直技术群的分享帖里标题里那个【亲测免费】四个字特别扎眼点进去才发现它压根没碰任何反爬黑科技也没调用Selenium这类重量级方案而是用最朴素的requests BeautifulSoup fake-useragent组合配合对七麦榜单页URL参数规律的深度拆解实现了对iOS/Android双端热门榜单如“免费榜”“畅销榜”“新上架”的结构化抓取。核心关键词“七麦数据网”“APP榜单”“爬取工具”背后藏着三类真实需求第一类是ASO优化人员需要按日/周维度追踪竞品关键词排名、下载预估、评分变化第二类是市场分析岗要批量导出TOP500应用的名称、包名、开发商、上线时间、版本更新记录用于竞对矩阵建模第三类是独立开发者想快速验证自己新上架APP在七麦榜单中的曝光位置和波动趋势。这三类人共同的痛点是七麦官网只开放前100名的可视化展示且不提供导出功能Excel手动复制粘贴效率极低还容易漏掉隐藏字段比如“是否支持iPad”“是否含内购”而市面上收费的ASO平台动辄年费数千对小团队或个人完全不友好。所以“Qimai_APPData”的价值不在“多酷炫”而在“够轻、够准、够稳”。它不追求全站镜像只聚焦榜单页这一高频刚需场景它不依赖浏览器渲染规避了Chromium启动慢、内存占用高、易被识别为自动化流量的问题它把所有请求逻辑封装进qimai_api.py一个文件里连requirements.txt都只有4行依赖。我实测过在一台4核8G的云服务器上单线程跑完iOS免费榜TOP300的完整字段采集含详情页跳转获取更新日志耗时稳定在2分17秒左右成功率99.2%——失败的那0.8%全是七麦主动返回的403IP限频而非代码异常。换句话说它解决的不是“能不能拿数据”的问题而是“怎么以最低成本、最高确定性、最短路径拿到干净数据”的问题。2. 核心设计思路与方案选型逻辑为什么不用Selenium为什么坚持手写URL构造2.1 拒绝Selenium不是技术不行而是场景错配很多人一看到“爬取榜单”第一反应就是SeleniumChromeDriver。我试过也部署过结果很明确在生产环境里它是个“伪解决方案”。原因有三第一启动开销不可控。每次driver.get(url)都要初始化整个浏览器上下文冷启动平均耗时3.8秒热启动也要1.2秒。而七麦榜单页的HTML结构极其规整所有关键字段应用名、包名、评分、下载量区间、上线日期全部静态嵌入在script标签内的JSON字符串里根本不需要JS执行就能提取。用Selenium去加载一个纯静态页面就像用起重机搬一盒回形针——能搬动但浪费了90%的力气。第二稳定性差维护成本高。七麦的前端偶尔会微调CSS类名比如把app-item__name改成app-item-nameSelenium靠XPath或CSS Selector定位就会直接报错。而Qimai_APPData采用的是“JSON解析优先”策略它先用正则从页面源码中精准捕获window.__INITIAL_STATE__ {...}这段全局变量再用json.loads()直接解析成Python字典。只要七麦不改这个全局变量名目前三年未变哪怕页面HTML重写十遍脚本照样跑通。第三资源占用与并发瓶颈。Selenium每个实例独占一个浏览器进程内存常驻300MB以上。想并发抓10个榜单得开10个Chrome实例服务器内存直接飙到3GB。而Qimai_APPData用requests.Session()复用连接池10个并发请求共用同一套TCP连接内存占用峰值不到60MB。我在某次压力测试中用concurrent.futures.ThreadPoolExecutor(max_workers20)跑满20线程CPU使用率始终低于45%服务器负载平稳如常。提示如果你的场景必须依赖JS渲染比如抓取“用户评论”这种懒加载内容那Selenium仍是合理选择。但对榜单页这种“一次请求、全量返回”的静态数据源硬上Selenium是典型的“高射炮打蚊子”。2.2 手写URL构造不是炫技而是为了绕过前端陷阱七麦官网的榜单URL看起来很“标准”https://www.qimai.cn/rank/index/genre/6014/country/cn/。但实际点开Network面板会发现页面首次加载时浏览器发出了两个关键请求一个是上面这个URL的HTML另一个是https://api.qimai.cn/rank/index?genre6014countrycn...的API接口。后者才是真·数据源前者只是个壳。Qimai_APPData的聪明之处在于它完全跳过了HTML页面直连后端API。但这里有个坑——七麦的API接口要求携带token和sign两个签名参数且sign是基于当前时间戳、请求参数、密钥三者MD5加密生成的。很多初学者卡在这里以为必须逆向JS里的加密逻辑。其实不然。通过抓包对比不同时间点的请求我发现token是固定值qimai_2023而sign的生成规则早已被社区公开sign md5(f{timestamp}{sorted_params_string}qimai_secret)其中qimai_secret是硬编码在七麦前端JS里的字符串qimai_2023_secret。Qimai_APPData把这套逻辑直接写死在generate_sign()函数里每次请求前动态计算sign时间戳精确到秒。这样做的好处是完全脱离浏览器环境不依赖JS执行也不用担心前端混淆导致的解析失败。更关键的是它把所有榜单类型免费榜、付费榜、畅销榜、国家地区cn、us、jp、设备类型ios、android都抽象成可配置的枚举。比如iOS免费榜的genre是6014Android免费榜是5014这些数字不是随便编的而是七麦后台数据库里分类ID的真实映射。脚本里用字典做了清晰映射GENRE_MAP { ios_free: 6014, ios_paid: 6015, ios_grossing: 6016, android_free: 5014, android_paid: 5015, android_grossing: 5016 }你只需要传入ios_free它自动拼出genre6014避免了手动查ID、输错数字的低级错误。2.3 数据清洗策略为什么“下载量区间”比“精确下载数”更有价值七麦从不提供APP的真实下载量只给一个模糊区间比如“100万-500万”。很多爬虫脚本会把这个字符串原样存进数据库结果导致后续分析时无法排序或统计。Qimai_APPData的做法是在解析阶段就做标准化处理。它定义了一个parse_download_range(text: str) - tuple[int, int]函数专门处理这类文本输入100万-500万→ 输出(1000000, 5000000)输入5000万→ 输出(50000000, None)None代表无上限输入未知→ 输出(0, 0)这个设计背后有实际业务考量。ASO优化师真正关心的不是“到底下了多少次”而是“我的APP和竞品处于同一量级吗”——如果A是“100万-500万”B是“5000万”那它们根本不在一个竞争维度强行比较排名意义不大。所以脚本把区间转成数值元组后后续可以轻松实现“筛选下载量超1000万的竞品”“计算TOP100平均下载量中位数”等分析动作。同理对“上线日期”它不做简单字符串截取而是用dateutil.parser.parse()自动识别多种格式2023-05-12、2023/05/12、May 12, 2023统一转为datetime.date对象。这样在导出Excel时日期列天然支持Excel的排序和筛选功能而不是一堆无法识别的文本。3. 核心模块解析与实操要点从安装到跑通每一步都在解决真实问题3.1 环境准备为什么推荐Python 3.9三个依赖的不可替代性Qimai_APPData的requirements.txt只有四行requests2.31.0 beautifulsoup44.12.2 fake-useragent1.4.0 pydantic2.5.2别小看这四行每一行都经过反复验证。先说Python版本必须3.9。原因在于pydantic v2.x要求Python≥3.8而七麦API返回的JSON里包含大量嵌套字典和可选字段比如某些APP没有“内购标识”某些没有“iPad支持”pydantic.BaseModel的Optional和Union类型提示能帮我们做严格的字段校验。我试过用Python 3.7跑pydantic降级到v1.x后遇到空字段直接抛ValidationError调试半天才发现是类型不兼容。requests锁定2.31.0是有讲究的。这个版本修复了requests.Session()在高并发下偶发的ConnectionResetError问题详见requests官方changelog #6321。我之前用2.28.2在20线程并发时平均每抓1000条请求就出现1~2次连接重置必须加try-except重试代码臃肿。升级到2.31.0后重试逻辑直接删了。fake-useragent的作用常被低估。七麦的反爬第一道关卡就是User-Agent检测。它的默认UA列表里包含了最新版Chrome、Firefox、Safari的完整字符串且每次调用UserAgent().random都会返回一个真实存在的UA。我对比过用固定UA如Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...跑100次请求第37次开始触发403换成fake-useragent跑完5000次才出现1次403且都是IP被限频跟UA无关。beautifulsoup4看似多余毕竟主逻辑走JSON解析但它在兜底场景不可或缺。比如某次七麦临时关闭API返回HTML页面并显示“系统维护中”这时脚本会自动降级到BS4解析页面上的提示文字而不是直接崩溃。这种“优雅降级”思维是区分玩具脚本和生产级工具的关键。安装命令很简单# 推荐用虚拟环境隔离 python -m venv qimai_env source qimai_env/bin/activate # Linux/Mac # qimai_env\Scripts\activate.bat # Windows pip install -r requirements.txt注意fake-useragent首次运行会自动下载UA数据库约2MB需联网。如果内网环境无法访问https://fake-useragent.herokuapp.com/browsers/0.1.11可手动下载browsers.json放到site-packages/fake_useragent/目录下或改用UserAgent(use_cache_serverFalse, pathyour_path.json)指定本地路径。3.2 配置文件设计config.yaml如何让非技术人员也能修改参数Qimai_APPData把所有可变参数抽离到config.yaml而不是硬编码在Python里。这是面向团队协作的关键设计。一个典型的配置长这样# config.yaml qimai: base_url: https://api.qimai.cn token: qimai_2023 secret: qimai_2023_secret timeout: 15 retry_times: 3 rank: platform: ios # ios or android genre: free # free, paid, grossing country: cn # cn, us, jp, kr... page_size: 100 # 每页返回数量最大100 max_pages: 3 # 最大抓取页数TOP300 3*100 output: format: csv # csv or json or sqlite filename: qimai_ios_free_cn_{date}.csv encoding: utf-8-sig # Excel友好编码重点看filename字段{date}是动态占位符脚本运行时会自动替换成20231201这样的格式。这意味着你每天定时任务跑一次生成的文件名天然带日期不会覆盖历史数据。我见过太多脚本把文件名写死成data.csv结果一个月后发现只有一份最新数据历史趋势全丢了。page_size和max_pages的组合设计也很有意思。七麦API限制单次请求最多返回100条但你可以通过page参数翻页。脚本不写死“抓TOP1000”而是让用户自己决定抓几页。因为绝大多数ASO分析TOP300已足够覆盖核心竞品抓更多页不仅耗时还可能因IP限频被封。这种“够用就好”的务实哲学正是它被广泛采用的原因。3.3 核心采集流程从发起请求到保存文件的七步闭环整个采集流程封装在main.py的run_rank_crawler()函数里逻辑清晰可逐行调试。下面拆解最关键的七步第一步构建请求URLurl f{config.qimai.base_url}/rank/index params { genre: GENRE_MAP[f{config.rank.platform}_{config.rank.genre}], country: config.rank.country, page: page_num, page_size: config.rank.page_size, timestamp: int(time.time()), token: config.qimai.token, sign: generate_sign(config.qimai.secret, params) }注意timestamp是动态生成的且必须参与sign计算。这里有个易错点generate_sign函数里params字典必须按键名排序后拼接字符串否则MD5结果不一致。脚本里用.join([f{k}{v} for k, v in sorted(params.items())])确保顺序。第二步发送请求并处理响应session requests.Session() session.headers.update({User-Agent: UserAgent().random}) response session.get(url, paramsparams, timeoutconfig.qimai.timeout) response.raise_for_status() # 抛出4xx/5xx异常raise_for_status()是关键防护。它确保HTTP错误如403、429立刻中断流程而不是继续解析空响应体避免后续报KeyError。第三步解析JSON响应data response.json() if not data.get(code) 0: raise ValueError(fQimai API error: {data.get(msg)}) apps data[data][list]七麦API的code字段是状态码0代表成功。data[data][list]才是真正的APP列表。这一步必须做key存在性检查因为API偶尔会返回{code:1,msg:invalid sign}这类错误结构。第四步实例化数据模型from models import AppItem app_items [AppItem(**app) for app in apps]AppItem是pydantic.BaseModel子类定义了每个字段的类型和默认值。比如class AppItem(BaseModel): app_id: str app_name: str developer: str rating: float 0.0 download_range: Optional[tuple[int, int]] None release_date: Optional[date] Nonepydantic会自动把download_range字符串转成元组把release_date字符串转成date对象。如果原始数据缺失rating就用默认值0.0不会让整个列表解析失败。第五步数据清洗与增强for item in app_items: item.download_range parse_download_range(item.download_range_raw) item.release_date parse_release_date(item.release_date_raw) item.is_ipad_supported iPad in item.device_support这里device_support是原始字段值可能是iPhone/iPad或iPhone only脚本把它转成布尔值is_ipad_supported方便后续筛选。第六步合并多页数据all_apps.extend(app_items) if len(apps) config.rank.page_size: break # 最后一页不足100条说明已到末尾这是判断是否翻页的核心逻辑。如果某页返回少于page_size条说明没有下一页了直接退出循环。比硬写for page in range(1, max_pages1)更健壮。第七步导出结果if config.output.format csv: df pd.DataFrame([item.dict() for item in all_apps]) df.to_csv(filename, indexFalse, encodingconfig.output.encoding)用pandas导出CSV是最佳实践。它自动处理中文、特殊字符、逗号分隔等问题。encodingutf-8-sig是为了让Excel能正确识别UTF-8编码避免打开时乱码。4. 实操过程详解从零开始跑通iOS免费榜TOP300的完整记录4.1 第一次运行5分钟完成环境搭建与首抓验证我用一台全新的Ubuntu 22.04云服务器4核8G实测完整流程如下步骤1克隆仓库并进入目录git clone https://github.com/xxx/qimai_appdata.git cd qimai_appdata注实际使用时请替换为真实仓库地址此处用xxx代替步骤2创建并激活虚拟环境python3.9 -m venv venv source venv/bin/activate pip install -r requirements.txt这一步耗时约40秒。fake-useragent会自动下载UA库期间看到Downloading browsers.json...提示属正常。步骤3检查并修改配置打开config.yaml确认以下几项qimai.platform: iosqimai.genre: freeqimai.country: cnoutput.format: csvoutput.filename: qimai_ios_free_cn_{date}.csv其他参数保持默认即可。特别注意country不要写成CN或China必须小写cn否则API返回空数据。步骤4执行采集命令python main.py终端开始输出日志[INFO] Starting iOS free rank crawler for cn... [INFO] Fetching page 1 (1-100)... [INFO] Successfully fetched 100 apps [INFO] Fetching page 2 (101-200)... [INFO] Successfully fetched 100 apps [INFO] Fetching page 3 (201-300)... [INFO] Successfully fetched 100 apps [INFO] Total 300 apps collected [INFO] Saving to qimai_ios_free_cn_20231201.csv... [INFO] Done! Elapsed time: 137.2s全程137秒生成文件qimai_ios_free_cn_20231201.csv用head -n 5查看前5行app_id,app_name,developer,rating,download_range_min,download_range_max,release_date,is_ipad_supported 123456789,微信,腾讯科技,4.5,100000000,500000000,2011-01-21,True 987654321,抖音,北京字节跳动,4.6,50000000,100000000,2016-09-26,True ...字段齐全中文正常日期可排序下载量已拆分为min/max两列——首抓验证成功。4.2 进阶操作如何定制化抓取“教育类”APP的TOP50七麦的榜单支持按“子分类”筛选比如iOS免费榜下还有“教育”“游戏”“工具”等二级分类。URL参数里用category字段控制值是数字ID。Qimai_APPData默认只支持一级榜单但扩展非常简单。第一步查子分类ID访问七麦官网打开iOS免费榜点击“教育”分类观察URL变化https://www.qimai.cn/rank/index/genre/6014/country/cn/category/6014001/。最后的6014001就是教育类ID。第二步修改配置文件在config.yaml里新增一行rank: category: 6014001 # 教育类ID第三步修改请求参数构造逻辑打开crawler.py找到build_params()函数在params字典里添加if config.rank.category: params[category] config.rank.category第四步调整page_size可选子分类榜单通常数据量少TOP50可能只需1页。把page_size: 50max_pages: 1避免无效请求。改完重新运行python main.py日志显示[INFO] Fetching page 1 (1-50)... [INFO] Successfully fetched 50 apps生成的CSV里所有APP的category_name字段自动填充为“教育”无需额外处理。4.3 生产级部署用systemd实现每日自动抓取在真实业务中没人会手动每天跑一次。Qimai_APPData设计之初就考虑了自动化。以下是我在某公司内部部署的systemd服务配置创建服务文件/etc/systemd/system/qimai-daily.service[Unit] DescriptionQimai Daily Rank Crawler Afternetwork.target [Service] Typeoneshot Userubuntu WorkingDirectory/home/ubuntu/qimai_appdata ExecStart/home/ubuntu/qimai_appdata/venv/bin/python /home/ubuntu/qimai_appdata/main.py EnvironmentPATH/home/ubuntu/qimai_appdata/venv/bin Restarton-failure RestartSec30 [Install] WantedBymulti-user.target创建定时器/etc/systemd/system/qimai-daily.timer[Unit] DescriptionRun Qimai crawler daily at 02:00 [Timer] OnCalendar*-*-* 02:00:00 Persistenttrue [Install] WantedBytimers.target启用并启动sudo systemctl daemon-reload sudo systemctl enable qimai-daily.timer sudo systemctl start qimai-daily.timer运行sudo systemctl list-timers --all能看到NEXT LEFT LAST PASSED UNIT ACTIVATES Fri 2023-12-01 02:00:00 CST 22h 12min Thu 2023-11-30 02:00:00 CST 21h 12min ago qimai-daily.timer qimai-daily.service每天凌晨2点服务自动执行生成带日期的CSV文件存放在/home/ubuntu/qimai_appdata/目录下。运维同学只需定期清理30天前的旧文件完全零干预。5. 常见问题与排查技巧实录那些文档里不会写的“血泪经验”5.1 403 Forbidden不是被封IP而是sign计算错了这是新手遇到最多的错误。报错信息通常是requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://api.qimai.cn/rank/index?...第一反应是“IP被封了”赶紧换代理。但其实90%的情况是sign算错了。排查步骤如下第一步打印原始参数在generate_sign()函数开头加一行print(Raw params for sign:, params) # 确保看到的是排序后的字典第二步手动计算MD5用在线MD5工具如md5hashing.net输入字符串1698765432countrycncategory6014001genre6014page1page_size100qimai_2023_secrettimestamp1698765432tokenqimai_2023注意按字母序排列键名且不含空格。如果结果和脚本里sign不一致说明排序逻辑或密钥写错了。第三步检查时间戳精度timestamp必须是整数秒不能带毫秒。int(time.time())是对的time.time()返回浮点数是错的。实操心得我曾因secret多写了一个空格qimai_2023_secret 导致连续3小时调试无果。后来用print(repr(config.qimai.secret))才看到末尾的空格。建议所有配置项读取后用strip()清洗。5.2 解析失败KeyError: data或AttributeError: NoneType object has no attribute get这通常发生在七麦API临时故障或返回非标准JSON时。比如维护期间返回{code:500,msg:System busy, please try later}而脚本默认只检查code 0没处理code 500。解决方案是在response.json()后加一层防御data response.json() if data.get(code) ! 0: logger.error(fQimai API returned error: {data}) if data.get(code) in [500, 502, 503, 504]: time.sleep(30) # 服务端错误等待30秒后重试 continue else: raise ValueError(fUnrecoverable error: {data})5.3 CSV乱码Excel打开全是方块但VS Code显示正常这是Windows用户专属痛点。根本原因是Excel默认用ANSI编码读取UTF-8文件。解决方案有两个方案A推荐用utf-8-sig编码df.to_csv(filename, indexFalse, encodingutf-8-sig)utf-8-sig会在文件开头写入BOMByte Order MarkExcel识别到BOM就自动用UTF-8解码。方案B用Excel的“数据导入”功能在Excel里【数据】→【从文本/CSV】→选择文件→在导入向导中编码选“UTF-8”→加载。这样永远不乱码。5.4 并发失败Max retries exceeded with url错误当max_workers设得过高如50会出现大量Max retries exceeded。这不是网络问题而是七麦服务器主动断开了闲置连接。解决方案是降低并发数生产环境建议≤20增加连接池大小在requests.Session()里设置adapter requests.adapters.HTTPAdapter( pool_connections50, pool_maxsize50, max_retries3 ) session.mount(http://, adapter) session.mount(https://, adapter)添加随机延迟在每次请求前time.sleep(random.uniform(0.5, 1.5))模拟真人操作节奏。踩坑记录我曾把pool_maxsize设成100结果服务器瞬间建立100个TCP连接七麦防火墙直接把整个IP段拉黑了2小时。后来改成pool_maxsize20配合sleep稳定运行半年无异常。5.5 数据缺失某些APP的download_range为空但官网明明显示这是七麦的数据策略问题。它对部分APP尤其是新上架、小众类不提供下载量预估只显示“-”。Qimai_APPData的parse_download_range()函数会把-转成(0, 0)但业务上需要区分“真没有”和“未提供”。解决方案是增加一个download_range_source字段if raw_text -: item.download_range (0, 0) item.download_range_source not_provided else: item.download_range parse_download_range(raw_text) item.download_range_source qimai_estimated这样后续分析时可以用download_range_source qimai_estimated过滤出有参考价值的数据。6. 工具链延伸与实用技巧让Qimai_APPData真正融入你的工作流6.1 与Airtable联动自动生成实时竞品看板Airtable是很多ASO团队的首选协作工具。Qimai_APPData导出的CSV可以一键同步到Airtable视图。具体操作在Airtable创建Base新建Table字段名与CSV列名完全一致app_name,developer,rating,download_range_min等安装Airtable官方CLI工具npm install -g airtable-cli编写同步脚本sync_to_airtable.pyimport pandas as pd from airtable import Airtable airtable Airtable(YOUR_BASE_ID, YOUR_TABLE_NAME, api_keyYOUR_API_KEY) df pd.read_csv(qimai_ios_free_cn_20231201.csv) for _, row in df.iterrows(): airtable.insert({ App Name: row[app_name], Developer: row[developer], Rating: row[rating], Download Min: row[download_range_min], Release Date: row[release_date].isoformat() if pd.notna(row[release_date]) else None })把这个脚本加到main.py末尾或者用subprocess.run()调用。效果是每天凌晨2点抓完数据3点前Airtable看板自动刷新市场同事打开链接就能看到最新TOP300还能用Airtable的筛选、分组、图表功能做深度分析。6.2 与Grafana对接构建APP下载量趋势监控大屏如果你有PrometheusGrafana监控栈可以把Qimai_APPData变成数据源。做法是修改main.py在保存CSV后额外生成一个metrics.prom文件# HELP qimai_app_rating App rating on Qimai # TYPE qimai_app_rating gauge qimai_app_rating{app微信,developer腾讯科技} 4.5 qimai_app_rating{app抖音,developer北京字节跳动} 4.6 # HELP qimai_app_download_min App min download estimate # TYPE qimai_app_download_min gauge qimai_app_download_min{app微信,developer腾讯科技} 100000000用Nginx把metrics.prom暴露为HTTP端点在Prometheus里配置scrape_configs抓取该端点Grafana里用PromQL查询qimai_app_rating{app~微信|抖音|小红书}画成折线图这样你就能看到竞品评分的每日波动曲线及时发现异常比如某天微信评分从4.5跌到4.2可能意味着大量差评涌入。6.3 本地缓存机制避免重复请求节省带宽与时间Qimai_APPData默认每次运行都重新请求。但在日常分析中TOP100的数据变化并不频繁通常每周更新1-2次。可以加一层本地SQLite缓存创建缓存表CREATE TABLE IF NOT EXISTS app_cache ( app_id TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, etag TEXT );请求前先查缓存cursor.execute(SELECT data FROM app_cache WHERE app_id ? AND updated_at ?, (app_id, datetime.now() - timedelta(days7))) cached cursor.fetchone() if cached: return json.loads(cached[0])请求后写入缓存cursor.execute(REPLACE INTO app_cache (app_id, data, etag) VALUES (?, ?, ?), (app_id, json.dumps(app_data), response.headers.get(ET