
Gzh Search
- 1 installs
- 316 repo stars
- Updated August 4, 2026
- redfox-data/redfox-community
Searches WeChat public-account articles by keyword, shows them in a terminal table with engagement metrics, exports to Excel, and generates an interactive HTML report.
About
A WeChat article search engine that returns articles by keyword with read, like, share, and save metrics in a terminal table. A content creator uses it to find articles on a topic and export the results to Excel or an HTML report.
- Keyword search with read/like/share/save metrics in a terminal table
- One-click Excel export and interactive HTML report with search box
Gzh Search by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,710 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/redfox-data/redfox-community --skill gzh-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 316 |
| Last updated | August 4, 2026 |
| Repository | redfox-data/redfox-community ↗ |
What it does
Searches WeChat public-account articles by keyword, shows them in a terminal table with engagement metrics, exports to Excel, and generates an interactive HTML report.
Files
公众号文章搜索
你的公众号内容搜索引擎。输入任意关键词,实时搜索微信公众号文章,终端表格清晰展示:标题、作者、阅读数、点赞数、分享数、收藏数、发布时间,数据一键导出 Excel,并生成带搜索框的交互式 HTML 报告。
API Source: 公众号搜索SkillHub — 所有 API 请求均携带此标识,用于区分不同 Skill 的调用来源。---
首次使用
开箱即用 — 粘贴命令就能跑
内置公共 API Key,约 10000 次免费额度,零配置直接用:
# 搜索公众号文章
python3 "$SKILL_PATH/assets/search.py" "人工智能"
# 指定排序方式和每页数量
python3 "$SKILL_PATH/assets/search.py" "AI Agent" --sort-type default --page-size 20
# 生成并打开交互式 HTML 报告
python3 "$SKILL_PATH/assets/search.py" "大模型" --openHTML 报告页面内置搜索框,支持实时调接口查询任意关键词的公众号文章,结果以卡片形式展示(标题、作者、阅读/点赞/分享/收藏数据),点击卡片直跳原文阅读。
CSV 数据文件保存在 ~/Downloads/QoderGzhSearch/ 目录。
---
后续使用
免费额度用完后,前往 redfox.hk/login 注册账号获取自己的 API Token:
| 配置方式 | 说明 | 命令 |
|---|---|---|
| 环境变量(推荐) | 设置一次,全局生效 | export X_API_KEY=ak_你的密钥 |
| 命令行参数 | 临时使用 | python3 "$SKILL_PATH/assets/search.py" "关键词" --api-key ak_你的密钥 |
| 配置文件 | 持久化存储 | echo '{"api_key":"ak_你的密钥"}' > ~/.qoder/apis/redfox.json |
---
功能特点
- 关键词搜索:输入任意关键词,实时查询微信公众号文章
- 终端表格展示:标题、作者、阅读数、点赞数、分享数、收藏数、发布时间一览无余
- 数据导出:搜索完成后自动生成 CSV 文件,方便数据分析
- HTML 交互报告:生成自包含 HTML 页面,内置搜索框支持实时调接口查询
- 原文直达:在 HTML 报告中点击任意文章卡片,直接跳转到公众号文章阅读链接
- 本地服务模式:内置 HTTP 服务代理 API 请求,避免浏览器跨域限制
---
使用方式
基础搜索
# 搜索指定关键词的公众号文章
python3 "$SKILL_PATH/assets/search.py" "人工智能"
# 一次搜索多个关键词(分别执行,可手动多次调用)
python3 "$SKILL_PATH/assets/search.py" "AI Agent"
python3 "$SKILL_PATH/assets/search.py" "大模型"
python3 "$SKILL_PATH/assets/search.py" "ChatGPT"高级参数
# 指定获取数量(API 每页固定约 20 条,多页自动翻页)
python3 "$SKILL_PATH/assets/search.py" "AI" --count 50
# 不自动打开浏览器
python3 "$SKILL_PATH/assets/search.py" "AI" --no-open
# 指定输出目录
python3 "$SKILL_PATH/assets/search.py" "AI" --output-dir ~/Desktop
# 只生成 CSV 不生成 HTML
python3 "$SKILL_PATH/assets/search.py" "AI" --csv-only交互式 HTML 报告
运行搜索后自动生成 HTML 报告并打开浏览器。在 HTML 页面的搜索框中输入关键词,可实时调接口搜索更多公众号文章,无需重新运行脚本。
报告特性:
- 深色主题,响应式布局
- 搜索框支持防抖输入(300ms 延迟)
- 每篇文章展示:标题、作者、阅读数、点赞数、分享数、收藏数、发布时间
- 点击文章卡片直接跳转到公众号原文
- 支持分页加载更多
参数说明
| 参数 | 说明 | 默认值 |
|---|---|---|
keyword | 搜索关键词(必填,位置参数) | — |
--count | 获取文章数量,多页自动翻页 | 20 |
--sort-type | 排序方式:default / time | default |
--output-dir | 输出目录 | ~/Downloads/QoderGzhSearch |
--api-key | 指定 API Key | — |
--open | 自动打开浏览器查看 HTML 报告 | 默认开启 |
--no-open | 不自动打开浏览器 | — |
--csv-only | 仅生成 CSV 文件,不生成 HTML | — |
--port | HTML 报告本地服务端口 | 8766 |
依赖安装
| 依赖 | 安装命令 |
|---|---|
requests | pip3 install requests |
---
常见问题
Q:搜索范围是什么? A:搜索全量微信公众号文章,不限定特定领域或账号。
Q:最多一次性返回多少条数据? A:单次请求默认返回 20 条,可通过 --page-size 调整。HTML 报告页面支持分页加载更多结果。
Q:数据保存在哪? A:CSV 文件默认保存在 ~/Downloads/QoderGzhSearch/,文件名格式 公众号搜索_[关键词]_2026-05-27.csv。
Q:为什么 HTML 报告需要本地服务? A:浏览器直接打开 HTML 文件时可能存在跨域限制,无法直接调用 redfox.hk API。内置本地 HTTP 服务作为代理中转,确保搜索功能正常。
Q:搜索有文章封面图吗? A:搜索接口暂不返回封面图,HTML 报告中使用文章标题首字作为卡片视觉标识。
Q:额度用完了怎么办? A:前往 redfox.hk/login 注册获取免费 API Token。
---
获取方式
本 Skill 可在以下平台找到:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>公众号搜索 · {{KEYWORD}} | {{DATE}}</title>
<!-- Fonts: Space Grotesk (body) + Inter (fallback) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ═══════════════════════════════════════════════
CSS CUSTOM PROPERTIES — EMERALD SEARCH
═══════════════════════════════════════════════ */
:root {
--bg-primary: #0a0f0a;
--bg-secondary: #0f1a0f;
--bg-card: #141f14;
--bg-card-hover: #1a2a1a;
--text-primary: #e8efe8;
--text-secondary: #8a9a8a;
--text-muted: #556655;
--accent: #2ECC71;
--accent-light: #58D68D;
--accent-glow: rgba(46, 204, 113, 0.12);
--accent-border: rgba(46, 204, 113, 0.25);
--border: rgba(255, 255, 255, 0.05);
--border-hover: rgba(46, 204, 113, 0.3);
--success: #27AE60;
--warning: #F39C12;
--error: #E74C3C;
--font-body: 'Space Grotesk', -apple-system, sans-serif;
--radius: 12px;
--radius-sm: 8px;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ═══════════════════════════════════════════════
RESET & BASE
═══════════════════════════════════════════════ */
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
/* ═══════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════ */
.main-header {
text-align: center;
padding: 2.5rem 1.5rem 1.5rem;
position: relative;
max-width: 900px;
margin: 0 auto;
}
.main-header::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 2px;
background: var(--accent);
border-radius: 1px;
opacity: 0.5;
}
.main-header h1 {
font-size: clamp(1.4rem, 3vw, 2rem);
font-weight: 700;
color: var(--accent);
letter-spacing: -0.02em;
margin-bottom: 0.3rem;
}
.main-header .subtitle {
color: var(--text-secondary);
font-size: clamp(0.8rem, 1.2vw, 0.9rem);
font-weight: 300;
}
/* ═══════════════════════════════════════════════
STATS BAR
═══════════════════════════════════════════════ */
.stats-bar {
display: flex;
justify-content: center;
gap: 2rem;
padding: 1rem 1rem;
margin: 0 auto;
max-width: 600px;
flex-wrap: wrap;
}
.stat-item {
text-align: center;
min-width: 60px;
}
.stat-value {
font-size: clamp(1.1rem, 2vw, 1.4rem);
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: clamp(0.65rem, 0.8vw, 0.75rem);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ═══════════════════════════════════════════════
SEARCH BAR
═══════════════════════════════════════════════ */
.search-container {
position: sticky;
top: 0;
z-index: 100;
background: linear-gradient(180deg, var(--bg-primary) 80%, transparent);
padding: 1rem 1.5rem 1.5rem;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.search-wrapper {
max-width: 640px;
margin: 0 auto;
display: flex;
gap: 0.6rem;
align-items: center;
}
.search-input {
flex: 1;
padding: 0.75rem 1.2rem;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.95rem;
font-family: var(--font-body);
outline: none;
transition: all 0.2s var(--ease-out);
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.search-info {
margin-top: 0.6rem;
text-align: center;
font-size: 0.75rem;
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════
RESULTS GRID
═══════════════════════════════════════════════ */
.results-container {
max-width: 900px;
margin: 0 auto;
padding: 0.5rem 1.5rem 2rem;
}
.results-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* ── Article Card ── */
.article-card {
display: flex;
align-items: flex-start;
gap: 0.9rem;
padding: 0.9rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: none;
color: inherit;
transition: all 0.15s var(--ease-out);
position: relative;
}
.article-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-hover);
transform: translateX(2px);
}
.article-card:active {
transform: scale(0.995);
}
/* ── Card Left: Avatar ── */
.card-avatar {
width: 42px;
height: 42px;
min-width: 42px;
border-radius: var(--radius-sm);
background: var(--accent-glow);
border: 1px solid var(--accent-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
color: var(--accent);
user-select: none;
}
/* ── Card Right: Content ── */
.card-content {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 0.92rem;
font-weight: 500;
line-height: 1.45;
color: var(--text-primary);
margin-bottom: 0.35rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.card-meta .author {
color: var(--accent-light);
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta .metric {
display: inline-flex;
align-items: center;
gap: 0.2rem;
white-space: nowrap;
}
.card-meta .pub-time {
color: var(--text-muted);
margin-left: auto;
}
/* ═══════════════════════════════════════════════
STATUS & LOADING
═══════════════════════════════════════════════ */
.status-message {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.status-message.loading {
color: var(--accent);
}
.status-message.error {
color: var(--error);
}
.status-message.empty {
color: var(--text-muted);
}
.loading-dots::after {
content: '';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
100% { content: ''; }
}
/* ═══════════════════════════════════════════════
LOAD MORE
═══════════════════════════════════════════════ */
.load-more-bar {
display: flex;
justify-content: center;
padding: 1.5rem 1rem 3rem;
}
.load-more-btn {
padding: 0.65rem 2.2rem;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 0.85rem;
font-family: var(--font-body);
cursor: pointer;
transition: all 0.2s var(--ease-out);
}
.load-more-btn:hover {
border-color: var(--accent-border);
color: var(--accent);
background: var(--bg-card-hover);
}
.load-more-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ═══════════════════════════════════════════════
FOOTER
═══════════════════════════════════════════════ */
.footer {
text-align: center;
padding: 2rem 1rem;
color: var(--text-muted);
font-size: 0.72rem;
border-top: 1px solid var(--border);
max-width: 900px;
margin: 0 auto;
}
/* ═══════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════ */
@media (max-width: 640px) {
.main-header { padding: 1.8rem 1rem 1rem; }
.search-wrapper { flex-direction: column; }
.search-input { padding: 0.7rem 1rem; }
.article-card { padding: 0.75rem 0.8rem; gap: 0.7rem; }
.card-title { font-size: 0.85rem; }
.card-meta { font-size: 0.7rem; gap: 0.4rem 0.6rem; }
.card-meta .pub-time { margin-left: 0; }
.stats-bar { gap: 1.2rem; }
}
/* ═══════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════ */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.article-card {
animation: fadeInUp 0.3s var(--ease-out) both;
}
.article-card:nth-child(1) { animation-delay: 0.00s; }
.article-card:nth-child(2) { animation-delay: 0.03s; }
.article-card:nth-child(3) { animation-delay: 0.06s; }
.article-card:nth-child(4) { animation-delay: 0.09s; }
.article-card:nth-child(5) { animation-delay: 0.12s; }
.article-card:nth-child(6) { animation-delay: 0.15s; }
.article-card:nth-child(7) { animation-delay: 0.18s; }
.article-card:nth-child(8) { animation-delay: 0.21s; }
.article-card:nth-child(9) { animation-delay: 0.24s; }
.article-card:nth-child(10) { animation-delay: 0.27s; }
.article-card:nth-child(n+11) { animation-delay: 0.30s; }
</style>
</head>
<body>
<!-- ═══════════ HEADER ═══════════ -->
<header class="main-header">
<h1>公众号文章搜索</h1>
<p class="subtitle">{{DATE}} | 输入关键词实时搜索微信公众号文章</p>
</header>
<!-- ═══════════ STATS ═══════════ -->
<div class="stats-bar" id="statsBar">
<div class="stat-item">
<div class="stat-value" id="statTotal">{{TOTAL_COUNT}}</div>
<div class="stat-label">结果总数</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statKeyword">{{KEYWORD}}</div>
<div class="stat-label">当前关键词</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statLoaded">{{TOTAL_COUNT}}</div>
<div class="stat-label">已加载</div>
</div>
</div>
<!-- ═══════════ SEARCH BAR ═══════════ -->
<div class="search-container">
<div class="search-wrapper">
<input
type="text"
class="search-input"
id="searchInput"
placeholder="输入关键词搜索公众号文章..."
value="{{KEYWORD}}"
autofocus
>
</div>
<div class="search-info" id="searchInfo">
输入关键词后自动搜索(300ms 防抖) | 点击文章跳转原文阅读
</div>
</div>
<!-- ═══════════ RESULTS ═══════════ -->
<main class="results-container">
<div class="results-grid" id="resultsGrid">
<!-- 动态填充 -->
</div>
<div class="status-message loading" id="statusMsg" style="display:none;">
<span class="loading-dots">搜索中</span>
</div>
<div class="status-message empty" id="emptyMsg" style="display:none;">
暂无搜索结果,请尝试其他关键词
</div>
<div class="load-more-bar" id="loadMoreBar" style="display:none;">
<button class="load-more-btn" id="loadMoreBtn" onclick="loadMore()">加载更多结果</button>
</div>
</main>
<!-- ═══════════ FOOTER ═══════════ -->
<footer class="footer">
Generated at {{TIMESTAMP}} by 公众号搜索 Skill | Powered by redfox.hk
</footer>
<!-- ═══════════ SCRIPTS ═══════════ -->
<script>
// ─── 配置 ────────────────────────────────────────────────────────────────────
const API_URL = '{{API_URL}}';
const API_KEY = '{{API_KEY}}';
const SOURCE = '{{SOURCE}}';
const INITIAL_DATA = {{INITIAL_DATA}};
// ─── 状态 ────────────────────────────────────────────────────────────────────
let currentKeyword = '{{KEYWORD}}';
let currentOffset = 0;
let hasMore = true;
let totalCount = {{TOTAL_COUNT}};
let isLoading = false;
let accumulatedArticles = [];
// ─── DOM 引用 ─────────────────────────────────────────────────────────────────
const resultsGrid = document.getElementById('resultsGrid');
const statusMsg = document.getElementById('statusMsg');
const emptyMsg = document.getElementById('emptyMsg');
const loadMoreBar = document.getElementById('loadMoreBar');
const loadMoreBtn = document.getElementById('loadMoreBtn');
const searchInput = document.getElementById('searchInput');
const searchInfo = document.getElementById('searchInfo');
const statTotal = document.getElementById('statTotal');
const statLoaded = document.getElementById('statLoaded');
const statKeyword = document.getElementById('statKeyword');
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
function formatNum(n) {
if (n == null || n === undefined) return '0';
n = Number(n);
if (isNaN(n)) return '0';
if (n >= 10000) return (n / 10000).toFixed(1) + 'w';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function getAvatarChar(title) {
if (!title) return '文';
// 取第一个非标点非空格的中文或英文字符
const cleaned = title.replace(/[^\w\u4e00-\u9fff]/g, '').trim();
return cleaned.charAt(0) || '文';
}
// ─── 渲染文章列表 ─────────────────────────────────────────────────────────────
function renderArticleCard(article) {
const title = article.title || '无标题';
const author = article.author || '未知作者';
const reads = formatNum(article.readCount || article.clicksCount);
const likes = formatNum(article.likeCount);
const shares = formatNum(article.shareCount);
const collects = formatNum(article.collectCount);
const pubTime = (article.publishTime || article.publicTime || '').slice(0, 10);
const url = article.workUrl || article.url || '#';
const cover = article.coverUrl || '';
const avatar = getAvatarChar(title);
let avatarHTML;
if (cover) {
avatarHTML = `<img src="${cover}" alt="" loading="lazy">`;
} else {
avatarHTML = escapeHTML(avatar);
}
const card = document.createElement('a');
card.className = 'article-card';
card.href = url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
card.innerHTML = `
<div class="card-avatar">${avatarHTML}</div>
<div class="card-content">
<div class="card-title">${escapeHTML(title)}</div>
<div class="card-meta">
<span class="author" title="${escapeHTML(author)}">${escapeHTML(author)}</span>
<span class="metric">👁 ${reads}</span>
<span class="metric">👍 ${likes}</span>
<span class="metric">📤 ${shares}</span>
<span class="metric">⭐ ${collects}</span>
${pubTime ? `<span class="pub-time">${pubTime}</span>` : ''}
</div>
</div>`;
return card;
}
function renderArticles(articles, append) {
if (!append) {
resultsGrid.innerHTML = '';
}
articles.forEach(article => {
resultsGrid.appendChild(renderArticleCard(article));
});
}
function updateUI() {
statTotal.textContent = totalCount || accumulatedArticles.length;
statLoaded.textContent = accumulatedArticles.length;
statKeyword.textContent = currentKeyword || '-';
loadMoreBar.style.display = hasMore ? 'flex' : 'none';
}
// ─── 数据获取 ─────────────────────────────────────────────────────────────────
function fetchArticles(keyword, offset, append) {
return fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_KEY,
},
body: JSON.stringify({
keyword: keyword,
offset: offset,
sortType: 'default',
source: SOURCE,
}),
})
.then(r => r.json())
.then(res => {
if (res.code === 200 || res.code === 2000) {
const data = res.data || {};
const list = data.list || [];
hasMore = data.hasMore === 1;
totalCount = list.length > 0 ? (totalCount || list.length) : totalCount;
return list;
} else {
throw new Error(res.msg || 'API 返回错误');
}
});
}
function setLoading(loading) {
isLoading = loading;
if (loading) {
statusMsg.style.display = 'block';
emptyMsg.style.display = 'none';
loadMoreBtn.disabled = true;
} else {
statusMsg.style.display = 'none';
loadMoreBtn.disabled = false;
}
}
function showError(msg) {
statusMsg.style.display = 'block';
statusMsg.className = 'status-message error';
statusMsg.textContent = msg;
isLoading = false;
loadMoreBtn.disabled = false;
}
// ─── 搜索 ─────────────────────────────────────────────────────────────────────
function doSearch() {
const kw = searchInput.value.trim();
if (!kw) {
showEmpty();
return;
}
// 重置状态
currentKeyword = kw;
currentOffset = 0;
hasMore = true;
totalCount = 0;
accumulatedArticles = [];
resultsGrid.innerHTML = '';
setLoading(true);
updateUI();
fetchArticles(kw, 0, false)
.then(articles => {
if (!articles || articles.length === 0) {
showEmpty();
return;
}
accumulatedArticles = articles;
currentOffset = articles.length;
renderArticles(articles, false);
setLoading(false);
updateUI();
searchInfo.textContent = `关键词 "${kw}" · 已加载 ${articles.length} 条结果`;
})
.catch(err => {
showError('搜索失败: ' + err.message);
});
}
function loadMore() {
if (isLoading || !hasMore) return;
setLoading(true);
fetchArticles(currentKeyword, currentOffset, true)
.then(articles => {
if (!articles || articles.length === 0) {
hasMore = false;
setLoading(false);
updateUI();
return;
}
accumulatedArticles = accumulatedArticles.concat(articles);
currentOffset += articles.length;
renderArticles(articles, true);
setLoading(false);
updateUI();
searchInfo.textContent = `关键词 "${currentKeyword}" · 已加载 ${accumulatedArticles.length} 条结果`;
})
.catch(err => {
showError('加载失败: ' + err.message);
});
}
function showEmpty() {
resultsGrid.innerHTML = '';
statusMsg.style.display = 'none';
emptyMsg.style.display = 'block';
loadMoreBar.style.display = 'none';
statTotal.textContent = '0';
statLoaded.textContent = '0';
}
// ─── 防抖搜索 ─────────────────────────────────────────────────────────────────
let debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(doSearch, 300);
});
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
clearTimeout(debounceTimer);
doSearch();
}
});
// ─── 初始化 ───────────────────────────────────────────────────────────────────
function init() {
if (INITIAL_DATA && INITIAL_DATA.length > 0) {
accumulatedArticles = INITIAL_DATA;
currentOffset = INITIAL_DATA.length;
totalCount = INITIAL_DATA.length;
renderArticles(INITIAL_DATA, false);
updateUI();
searchInfo.textContent = `初始结果 · 共 ${INITIAL_DATA.length} 条 | 输入新关键词自动搜索`;
// 初始数据加载后,hasMore 取决于 API 返回,此处设为可能有更多
statusMsg.style.display = 'none';
} else {
showEmpty();
searchInfo.textContent = '输入关键词开始搜索公众号文章';
}
}
init();
</script>
</body>
</html>
#!/usr/bin/env python3
"""
公众号文章搜索 — 关键字搜索微信公众号文章
=========================================
输入关键词,实时搜索公众号文章,终端表格展示,
自动导出 CSV + 生成带搜索框的交互式 HTML 报告。
Usage:
python3 search.py "人工智能"
python3 search.py "AI" --count 30
python3 search.py "大模型" --csv-only
"""
import argparse
import csv
import json
import os
import subprocess
import sys
import time
import threading
from datetime import datetime
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
# ─── 配置 ─────────────────────────────────────────────────────────────────────────
API_URL = "https://redfox.hk/story/api/gzhData/searchArticle"
CONFIG_DIR = Path.home() / ".qoder" / "apis"
CONFIG_FILE = CONFIG_DIR / "redfox.json"
ENV_KEY = "X_API_KEY"
PUBLIC_API_KEY = "ak_db0e200c049b44288d46da0e758d53dd"
SOURCE = "公众号搜索SkillHub"
DEFAULT_OUTPUT_DIR = Path.home() / "Downloads" / "QoderGzhSearch"
DEFAULT_COUNT = 20
DEFAULT_PORT = 8766
# ─── 终端颜色 ──────────────────────────────────────────────────────────────────────
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
CYAN = "\033[96m"
BOLD = "\033[1m"
RESET = "\033[0m"
def info(msg):
print(f"{GREEN}[✓]{RESET} {msg}")
def warn(msg):
print(f"{YELLOW}[!]{RESET} {msg}")
def error(msg):
print(f"{RED}[✗]{RESET} {msg}")
def step(msg):
print(f"{CYAN}[→]{RESET} {msg}")
# ─── API Key 管理 ──────────────────────────────────────────────────────────────────
def get_api_key(cli_key=None):
if cli_key:
return cli_key
env_key = os.environ.get(ENV_KEY)
if env_key:
return env_key
if CONFIG_FILE.exists():
try:
data = json.loads(CONFIG_FILE.read_text())
key = data.get("api_key")
if key:
return key
except (json.JSONDecodeError, OSError):
pass
return PUBLIC_API_KEY
# ─── 字段兼容(新旧 API 格式)───────────────────────────────────────────────────────
def _reads(a):
return a.get("readCount") or a.get("clicksCount") or 0
def _likes(a):
return a.get("likeCount") or 0
def _shares(a):
return a.get("shareCount") or 0
def _collects(a):
return a.get("collectCount") or 0
def _url(a):
return a.get("workUrl") or a.get("url") or ""
def _uid(a):
return a.get("workUuid") or a.get("uuid") or ""
def _pub_time(a):
return a.get("publishTime") or a.get("publicTime") or ""
def _cover(a):
return a.get("coverUrl") or ""
def _title(a):
return a.get("title") or "无标题"
def _author(a):
return a.get("author") or "-"
def _summary(a):
return a.get("summary") or ""
def _account_type(a):
return a.get("accountType") or ""
# ─── 数据获取 ──────────────────────────────────────────────────────────────────────
def fetch_articles_batch(session, keyword, offset=0, sort_type="default"):
payload = {
"keyword": keyword,
"offset": offset,
"sortType": sort_type,
"source": SOURCE,
}
try:
resp = session.post(API_URL, json=payload, timeout=15)
result = resp.json()
except Exception as e:
error(f"请求失败: {e}")
return None
code = result.get("code")
if code == 3108:
warn("限频,等待 5s 后重试...")
time.sleep(5)
try:
resp = session.post(API_URL, json=payload, timeout=15)
result = resp.json()
code = result.get("code")
except Exception:
return None
if code not in (200, 2000):
if code in (3106, 3107):
error(f"API Key 错误 (code {code}): {result.get('msg', '')}")
else:
error(f"API 返回错误 (code {code}): {result.get('msg', '')}")
return None
data = result.get("data", {})
articles = data.get("list", [])
has_more = data.get("hasMore", 0)
return {"articles": articles, "hasMore": has_more}
def fetch_articles(session, keyword, max_count=20, sort_type="default"):
all_articles = []
seen_ids = set()
offset = 0
has_more = True
while len(all_articles) < max_count and has_more:
batch = fetch_articles_batch(session, keyword, offset=offset, sort_type=sort_type)
if batch is None:
break
new_count = 0
for article in batch["articles"]:
pid = _uid(article)
if pid and pid not in seen_ids:
seen_ids.add(pid)
all_articles.append(article)
new_count += 1
has_more = batch["hasMore"] == 1
offset += len(batch["articles"])
if new_count == 0:
break
if len(all_articles) < max_count:
time.sleep(0.3)
return {"articles": all_articles[:max_count], "hasMore": 1 if (has_more or len(all_articles) >= max_count) else 0}
def format_number(n):
if n is None:
return "0"
if n >= 10000:
return f"{n/10000:.1f}w"
if n >= 1000:
return f"{n/1000:.1f}k"
return str(n)
# ─── 终端表格 ──────────────────────────────────────────────────────────────────────
def print_terminal_table(articles, keyword):
if not articles:
print(f"\n{YELLOW}[✗] 关键词 \"{keyword}\" 暂无搜索结果{RESET}")
return
count = len(articles)
print(f"\n{BOLD}{'=' * 100}{RESET}")
print(f"{BOLD} 公众号搜索 · \"{keyword}\" · 共 {count} 条结果{RESET}")
print(f"{BOLD}{'=' * 100}{RESET}")
header = (f" {'序号':<4}{'标题':<32}{'作者':<14}"
f"{'阅读':>7}{'点赞':>6}{'分享':>6}{'收藏':>6}{'发布时间':<12}")
print(f" {YELLOW}{'─' * 93}{RESET}")
print(f" {YELLOW}{header}{RESET}")
print(f" {YELLOW}{'─' * 93}{RESET}")
for i, a in enumerate(articles, 1):
title = _title(a)
author = _author(a)
reads = format_number(_reads(a))
likes = format_number(_likes(a))
shares = format_number(_shares(a))
collects = format_number(_collects(a))
pub_time = _pub_time(a)[:10] if _pub_time(a) else ""
display_title = title[:30] + ".." if len(title) > 32 else title
display_author = author[:12] + ".." if len(author) > 14 else author
print(f" {i:<4}{display_title:<32}{display_author:<14}"
f"{reads:>7}{likes:>6}{shares:>6}{collects:>6}{pub_time:<12}")
print(f" {YELLOW}{'─' * 93}{RESET}\n")
# ─── CSV 导出 ──────────────────────────────────────────────────────────────────────
def export_csv(articles, keyword, output_dir):
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
date_str = datetime.now().strftime("%Y-%m-%d")
filename = f"公众号搜索_{keyword}_{date_str}.csv"
filepath = output_dir / filename
fieldnames = ["标题", "作者", "阅读数", "点赞数", "分享数", "收藏数",
"发布时间", "文章链接", "摘要", "公众号分类"]
with open(filepath, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for a in articles:
writer.writerow({
"标题": _title(a),
"作者": _author(a),
"阅读数": _reads(a),
"点赞数": _likes(a),
"分享数": _shares(a),
"收藏数": _collects(a),
"发布时间": _pub_time(a),
"文章链接": _url(a),
"摘要": _summary(a),
"公众号分类": _account_type(a),
})
return filepath
# ─── HTML 报告生成 ─────────────────────────────────────────────────────────────────
def generate_html(articles, keyword, api_key):
template_path = Path(__file__).parent / "report_template.html"
if template_path.exists():
template = template_path.read_text(encoding="utf-8")
else:
template = get_fallback_template()
date_str = datetime.now().strftime("%Y-%m-%d")
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
initial_data = json.dumps(articles, ensure_ascii=False)
html = template
html = html.replace("{{KEYWORD}}", keyword)
html = html.replace("{{DATE}}", date_str)
html = html.replace("{{TIMESTAMP}}", timestamp)
html = html.replace("{{TOTAL_COUNT}}", str(len(articles)))
html = html.replace("{{INITIAL_DATA}}", initial_data)
html = html.replace("{{API_KEY}}", api_key)
html = html.replace("{{API_URL}}", API_URL)
html = html.replace("{{SOURCE}}", SOURCE)
return html
def get_fallback_template():
return '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>公众号搜索 - {{KEYWORD}} | {{DATE}}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0f0a; --bg-card: #141f14; --bg-card-hover: #1a2a1a;
--text: #e8efe8; --text-secondary: #8a9a8a; --text-muted: #556655;
--accent: #2ECC71; --accent-glow: rgba(46,204,113,0.12);
--accent-border: rgba(46,204,113,0.25);
--border: rgba(255,255,255,0.05);
--font: 'Space Grotesk', -apple-system, sans-serif;
--radius: 12px; --radius-sm: 8px;
}
body {
font-family: var(--font); background: var(--bg);
color: var(--text); min-height: 100vh;
}
.main-header {
text-align: center; padding: 2.5rem 1.5rem 1.5rem;
max-width: 900px; margin: 0 auto; position: relative;
}
.main-header::after {
content: ''; position: absolute; bottom: 0; left: 50%;
transform: translateX(-50%); width: 60px; height: 2px;
background: var(--accent); opacity: 0.5;
}
.main-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--accent); }
.main-header .subtitle { color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.3rem; }
.stats-bar {
display: flex; justify-content: center; gap: 2rem;
padding: 1rem; max-width: 600px; margin: 0 auto; flex-wrap: wrap;
}
.stat-item { text-align: center; }
.stat-value { font-size: 1.2rem; font-weight: 700; color: var(--accent); }
.stat-label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; }
.search-container {
position: sticky; top: 0; z-index: 100;
background: linear-gradient(180deg, var(--bg) 80%, transparent);
padding: 1rem 1.5rem 1.5rem; backdrop-filter: blur(12px);
}
.search-wrapper {
max-width: 640px; margin: 0 auto; display: flex; gap: 0.6rem;
}
.search-input {
flex: 1; padding: 0.75rem 1.2rem;
border: 1.5px solid var(--border); border-radius: var(--radius);
background: #0f1a0f; color: var(--text); font-size: 0.95rem;
font-family: var(--font); outline: none;
transition: all 0.2s cubic-bezier(0.16,1,0.3,1);
}
.search-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.search-input::placeholder { color: var(--text-muted); }
.search-info { margin-top: 0.6rem; text-align: center; font-size: 0.75rem; color: var(--text-muted); }
.results-container { max-width: 900px; margin: 0 auto; padding: 0.5rem 1.5rem 2rem; }
.results-grid { display: flex; flex-direction: column; gap: 0.5rem; }
.article-card {
display: flex; align-items: flex-start; gap: 0.9rem;
padding: 0.9rem 1rem; background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--radius-sm);
cursor: pointer; text-decoration: none; color: inherit;
transition: all 0.15s cubic-bezier(0.16,1,0.3,1);
}
.article-card:hover {
background: var(--bg-card-hover); border-color: var(--accent-border);
transform: translateX(2px);
}
.card-avatar {
width: 48px; height: 48px; min-width: 48px;
border-radius: var(--radius-sm); overflow: hidden;
background: var(--accent-glow); border: 1px solid var(--accent-border);
display: flex; align-items: center; justify-content: center;
font-size: 1rem; font-weight: 700; color: var(--accent);
user-select: none;
}
.card-avatar img {
width: 100%; height: 100%; object-fit: cover;
}
.card-content { flex: 1; min-width: 0; }
.card-title {
font-size: 0.92rem; font-weight: 500; line-height: 1.45;
margin-bottom: 0.35rem;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card-meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem;
font-size: 0.75rem; color: var(--text-secondary);
}
.card-meta .author { color: #58D68D; font-weight: 500; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-meta .pub-time { color: var(--text-muted); margin-left: auto; }
.status-message { text-align: center; padding: 3rem 1rem; color: var(--text-secondary); font-size: 0.9rem; }
.load-more-bar { display: flex; justify-content: center; padding: 1.5rem 1rem 3rem; }
.load-more-btn {
padding: 0.65rem 2.2rem; border: 1.5px solid var(--border);
border-radius: var(--radius); background: var(--bg-card);
color: var(--text-secondary); font-size: 0.85rem;
font-family: var(--font); cursor: pointer;
transition: all 0.2s cubic-bezier(0.16,1,0.3,1);
}
.load-more-btn:hover { border-color: var(--accent-border); color: var(--accent); }
.load-more-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.footer {
text-align: center; padding: 2rem 1rem; color: var(--text-muted);
font-size: 0.72rem; border-top: 1px solid var(--border);
max-width: 900px; margin: 0 auto;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.article-card { animation: fadeInUp 0.3s cubic-bezier(0.16,1,0.3,1) both; }
.article-card:nth-child(1){animation-delay:0s}.article-card:nth-child(2){animation-delay:.03s}
.article-card:nth-child(3){animation-delay:.06s}.article-card:nth-child(4){animation-delay:.09s}
.article-card:nth-child(5){animation-delay:.12s}.article-card:nth-child(6){animation-delay:.15s}
.article-card:nth-child(7){animation-delay:.18s}.article-card:nth-child(8){animation-delay:.21s}
.article-card:nth-child(9){animation-delay:.24s}.article-card:nth-child(10){animation-delay:.27s}
.article-card:nth-child(n+11){animation-delay:.30s}
@media (max-width: 640px) {
.main-header { padding: 1.5rem 1rem 1rem; }
.article-card { padding: 0.75rem 0.8rem; gap: 0.7rem; }
.card-title { font-size: 0.85rem; }
.card-meta { font-size: 0.7rem; gap: 0.4rem 0.6rem; }
.card-meta .pub-time { margin-left: 0; }
.stats-bar { gap: 1.2rem; }
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="main-header"><h1>公众号文章搜索</h1><p class="subtitle">{{DATE}} | 输入关键词实时搜索微信公众号文章</p></div>
<div class="stats-bar">
<div class="stat-item"><div class="stat-value" id="statTotal">{{TOTAL_COUNT}}</div><div class="stat-label">结果总数</div></div>
<div class="stat-item"><div class="stat-value" id="statKeyword">{{KEYWORD}}</div><div class="stat-label">当前关键词</div></div>
<div class="stat-item"><div class="stat-value" id="statLoaded">{{TOTAL_COUNT}}</div><div class="stat-label">已加载</div></div>
</div>
<div class="search-container">
<div class="search-wrapper">
<input type="text" class="search-input" id="searchInput"
placeholder="输入关键词搜索公众号文章..." value="{{KEYWORD}}" autofocus>
</div>
<div class="search-info" id="searchInfo">输入关键词后自动搜索(300ms 防抖)| 点击文章跳转原文阅读</div>
</div>
<main class="results-container">
<div class="results-grid" id="resultsGrid"></div>
<div class="status-message" id="statusMsg" style="display:none">搜索中...</div>
<div class="status-message" id="emptyMsg" style="display:none;color:var(--text-muted)">暂无搜索结果,请尝试其他关键词</div>
<div class="load-more-bar" id="loadMoreBar" style="display:none">
<button class="load-more-btn" id="loadMoreBtn" onclick="loadMore()">加载更多结果</button>
</div>
</main>
<footer class="footer">Generated at {{TIMESTAMP}} by 公众号搜索 Skill | Powered by redfox.hk</footer>
<script>
const API_URL = '{{API_URL}}';
const API_KEY = '{{API_KEY}}';
const SOURCE = '{{SOURCE}}';
const INITIAL_DATA = {{INITIAL_DATA}};
let currentKeyword = '{{KEYWORD}}';
let currentOffset = 0;
let hasMore = true;
let totalCount = {{TOTAL_COUNT}};
let isLoading = false;
let accumulatedArticles = [];
const resultsGrid = document.getElementById('resultsGrid');
const statusMsg = document.getElementById('statusMsg');
const emptyMsg = document.getElementById('emptyMsg');
const loadMoreBar = document.getElementById('loadMoreBar');
const loadMoreBtn = document.getElementById('loadMoreBtn');
const searchInput = document.getElementById('searchInput');
const searchInfo = document.getElementById('searchInfo');
const statTotal = document.getElementById('statTotal');
const statLoaded = document.getElementById('statLoaded');
const statKeyword = document.getElementById('statKeyword');
function formatNum(n) {
if (n == null || n === undefined) return '0';
n = Number(n);
if (isNaN(n)) return '0';
if (n >= 10000) return (n/10000).toFixed(1)+'w';
if (n >= 1000) return (n/1000).toFixed(1)+'k';
return String(n);
}
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function getArticleUrl(a) { return a.workUrl || a.url || '#'; }
function getCoverUrl(a) { return a.coverUrl || ''; }
function getReadCount(a) { return a.readCount || a.clicksCount || 0; }
function getPubTime(a) { return (a.publishTime || a.publicTime || '').slice(0,10); }
function renderArticleCard(article) {
const title = article.title || '无标题';
const author = article.author || '未知作者';
const reads = formatNum(getReadCount(article));
const likes = formatNum(article.likeCount);
const shares = formatNum(article.shareCount);
const collects = formatNum(article.collectCount);
const pubTime = getPubTime(article);
const url = getArticleUrl(article);
const cover = getCoverUrl(article);
const card = document.createElement('a');
card.className = 'article-card';
card.href = url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
let avatarHTML;
if (cover) {
avatarHTML = '<img src="'+cover+'" alt="" loading="lazy">';
} else {
const ch = (title.replace(/[^\\w\\u4e00-\\u9fff]/g,'').trim().charAt(0)||'文');
avatarHTML = escapeHTML(ch);
}
card.innerHTML = '<div class="card-avatar">'+avatarHTML+'</div>' +
'<div class="card-content">' +
'<div class="card-title">'+escapeHTML(title)+'</div>' +
'<div class="card-meta">' +
'<span class="author" title="'+escapeHTML(author)+'">'+escapeHTML(author)+'</span>' +
'<span>👁 '+reads+'</span>' +
'<span>👍 '+likes+'</span>' +
'<span>📤 '+shares+'</span>' +
'<span>⭐ '+collects+'</span>' +
(pubTime ? '<span class="pub-time">'+pubTime+'</span>' : '') +
'</div></div>';
return card;
}
function renderArticles(articles, append) {
if (!append) resultsGrid.innerHTML = '';
articles.forEach(a => resultsGrid.appendChild(renderArticleCard(a)));
}
function updateUI() {
statTotal.textContent = totalCount || accumulatedArticles.length;
statLoaded.textContent = accumulatedArticles.length;
statKeyword.textContent = currentKeyword || '-';
loadMoreBar.style.display = hasMore ? 'flex' : 'none';
}
function setLoading(loading) {
isLoading = loading;
statusMsg.style.display = loading ? 'block' : 'none';
emptyMsg.style.display = 'none';
loadMoreBtn.disabled = loading;
}
function fetchArticlesFromAPI(keyword, offset) {
return fetch(API_URL, {
method: 'POST',
headers: {'Content-Type':'application/json','X-API-KEY':API_KEY},
body: JSON.stringify({keyword,offset,sortType:'default',source:SOURCE})
}).then(r => r.json()).then(res => {
if (res.code===200||res.code===2000) {
const list = res.data.list||[];
hasMore = res.data.hasMore===1;
return list;
} else {
throw new Error(res.msg||'API error');
}
});
}
function doSearch() {
const kw = searchInput.value.trim();
if (!kw) { showEmpty(); return; }
currentKeyword = kw;
currentOffset = 0;
hasMore = true;
totalCount = 0;
accumulatedArticles = [];
resultsGrid.innerHTML = '';
setLoading(true);
updateUI();
fetchArticlesFromAPI(kw,0).then(list => {
if (!list||!list.length) { showEmpty(); return; }
accumulatedArticles = list;
currentOffset = list.length;
renderArticles(list, false);
setLoading(false);
updateUI();
searchInfo.textContent = '关键词 "'+kw+'" · 已加载 '+list.length+' 条结果';
}).catch(e => {
statusMsg.style.display='block'; statusMsg.textContent='搜索失败: '+e.message;
isLoading = false;
});
}
function loadMore() {
if (isLoading||!hasMore) return;
setLoading(true);
fetchArticlesFromAPI(currentKeyword,currentOffset).then(list => {
if (!list||!list.length) { hasMore=false; setLoading(false); updateUI(); return; }
accumulatedArticles = accumulatedArticles.concat(list);
currentOffset += list.length;
renderArticles(list, true);
setLoading(false);
updateUI();
searchInfo.textContent = '关键词 "'+currentKeyword+'" · 已加载 '+accumulatedArticles.length+' 条结果';
}).catch(e => {
statusMsg.style.display='block'; statusMsg.textContent='加载失败: '+e.message;
isLoading = false;
});
}
function showEmpty() {
resultsGrid.innerHTML = '';
statusMsg.style.display='none';
emptyMsg.style.display='block';
loadMoreBar.style.display='none';
statTotal.textContent='0'; statLoaded.textContent='0';
}
let debounceTimer;
searchInput.addEventListener('input',function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(doSearch,300);
});
searchInput.addEventListener('keydown',function(e) {
if (e.key==='Enter') { clearTimeout(debounceTimer); doSearch(); }
});
function init() {
if (INITIAL_DATA && INITIAL_DATA.length>0) {
accumulatedArticles = INITIAL_DATA;
currentOffset = INITIAL_DATA.length;
totalCount = INITIAL_DATA.length;
renderArticles(INITIAL_DATA, false);
updateUI();
statusMsg.style.display='none';
searchInfo.textContent = '初始结果 · 共 '+INITIAL_DATA.length+' 条 | 输入新关键词自动搜索';
} else {
showEmpty();
searchInfo.textContent = '输入关键词开始搜索公众号文章';
}
}
init();
</script>
</body>
</html>'''
# ─── API 代理 HTTP 服务 ─────────────────────────────────────────────────────────────
class ProxyHTTPHandler(SimpleHTTPRequestHandler):
api_key = PUBLIC_API_KEY
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/api/search":
self._handle_search(parsed)
else:
super().do_GET()
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path == "/api/search":
self._handle_search_post()
else:
self.send_error(404)
def _handle_search(self, parsed):
params = parse_qs(parsed.query)
keyword = params.get("keyword", [""])[0]
offset = int(params.get("offset", ["0"])[0])
sort_type = params.get("sortType", ["default"])[0]
if not keyword:
self._send_json({"code": -1, "msg": "missing keyword"})
return
self._do_proxy_request({"keyword": keyword, "offset": offset, "sortType": sort_type, "source": SOURCE})
def _handle_search_post(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length else b"{}"
try:
req_data = json.loads(body)
except json.JSONDecodeError:
self._send_json({"code": -1, "msg": "invalid json"})
return
keyword = req_data.get("keyword", "")
offset = req_data.get("offset", 0)
sort_type = req_data.get("sortType", "default")
if not keyword:
self._send_json({"code": -1, "msg": "missing keyword"})
return
self._do_proxy_request({"keyword": keyword, "offset": offset, "sortType": sort_type, "source": SOURCE})
def _do_proxy_request(self, payload):
try:
resp = requests.post(API_URL, json=payload,
headers={"Content-Type": "application/json", "X-API-KEY": self.api_key}, timeout=15)
self._send_json(resp.json())
except Exception as e:
self._send_json({"code": -1, "msg": str(e)})
def _send_json(self, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-API-KEY")
self.end_headers()
def log_message(self, format, *args):
pass
def start_server(output_dir, api_key, port=8766):
ProxyHTTPHandler.api_key = api_key
os.chdir(str(output_dir))
server = HTTPServer(("127.0.0.1", port), ProxyHTTPHandler)
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
info(f"本地服务已启动: http://127.0.0.1:{port}")
return server
# ─── 主流程 ────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="公众号文章搜索 — 关键字搜索微信公众号文章",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 search.py "人工智能"
python3 search.py "AI Agent" --count 30
python3 search.py "大模型" --csv-only
python3 search.py "AI" --no-open --output-dir ~/Desktop
""",
)
parser.add_argument("keyword", nargs="?", default="", help="搜索关键词(必填)")
parser.add_argument("--count", type=int, default=DEFAULT_COUNT, help=f"获取文章数量 (默认: {DEFAULT_COUNT})")
parser.add_argument("--sort-type", default="default", help="排序方式: default / time (默认: default)")
parser.add_argument("--output-dir", help=f"输出目录 (默认: ~/Downloads/QoderGzhSearch)")
parser.add_argument("--api-key", help="API Key")
parser.add_argument("--no-open", action="store_true", help="不自动打开浏览器")
parser.add_argument("--csv-only", action="store_true", help="仅生成 CSV 文件")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"HTTP 服务端口 (默认: {DEFAULT_PORT})")
args = parser.parse_args()
banner = f"""{CYAN}{BOLD}
╔══════════════════════════════════════════╗
║ 公众号文章搜索 · GZH Search ║
║ 关键词查询 · 数据导出 · 交互报告 ║
╚══════════════════════════════════════════╝{RESET}
"""
print(banner)
if not HAS_REQUESTS:
error("缺少 requests 库,请安装: pip3 install requests")
sys.exit(1)
api_key = get_api_key(cli_key=args.api_key)
if api_key == PUBLIC_API_KEY:
print(f"{YELLOW}╔══════════════════════════════════════════════════╗{RESET}")
print(f"{YELLOW}║ 使用内置公共 API Key ║{RESET}")
print(f"{YELLOW}║ 约 10000 次免费额度,用完请注册: redfox.hk ║{RESET}")
print(f"{YELLOW}╚══════════════════════════════════════════════════╝{RESET}\n")
if not args.keyword:
try:
args.keyword = input(f"{CYAN}请输入搜索关键词: {RESET}").strip()
except (EOFError, KeyboardInterrupt):
print()
sys.exit(0)
if not args.keyword:
error("搜索关键词不能为空")
sys.exit(1)
keyword = args.keyword
max_count = args.count
sort_type = args.sort_type
output_dir = args.output_dir or str(DEFAULT_OUTPUT_DIR)
output_dir = os.path.expanduser(output_dir)
session = requests.Session()
session.headers.update({"Content-Type": "application/json", "X-API-KEY": api_key})
step(f"搜索关键词: \"{keyword}\" (最多 {max_count} 条)...")
result = fetch_articles(session, keyword, max_count=max_count, sort_type=sort_type)
if result is None:
error("搜索失败,请检查网络或 API Key")
sys.exit(1)
articles = result["articles"]
has_more = result["hasMore"]
print_terminal_table(articles, keyword)
if not articles:
warn("未获取到任何文章,请尝试其他关键词")
warn("如需搜索全量公众号内容,请访问 redfox.hk")
sys.exit(0)
reads_list = [_reads(a) for a in articles]
total_reads = sum(reads_list)
max_reads = max(reads_list) if reads_list else 0
print(f" {BOLD}统计:{RESET} 共 {len(articles)} 条 | 总阅读 {format_number(total_reads)} | "
f"最高阅读 {format_number(max_reads)} | {'还有更多数据' if has_more else '已加载全部'}")
step("导出 CSV ...")
csv_path = export_csv(articles, keyword, output_dir)
info(f"CSV 已保存: {csv_path}")
if not args.csv_only:
step("生成 HTML 报告 ...")
html_content = generate_html(articles, keyword, api_key)
date_str = datetime.now().strftime("%Y-%m-%d")
html_filename = f"公众号搜索_{keyword}_{date_str}.html"
html_dir = Path(output_dir)
html_dir.mkdir(parents=True, exist_ok=True)
html_path = html_dir / html_filename
html_path.write_text(html_content, encoding="utf-8")
info(f"HTML 报告已保存: {html_path}")
server = start_server(output_dir, api_key, args.port)
url = f"http://127.0.0.1:{args.port}/{html_filename}"
if not args.no_open:
step("打开浏览器...")
try:
subprocess.run(["open", url], check=True)
except Exception:
print(f" 请手动打开: {url}")
print(f"\n{GREEN}╔══════════════════════════════════════════════════╗{RESET}")
print(f"{GREEN}║ ✓ 搜索完成! HTML 报告: {url}{RESET}")
print(f"{GREEN}║ 按 Ctrl+C 停止本地服务 ║{RESET}")
print(f"{GREEN}╚══════════════════════════════════════════════════╝{RESET}\n")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print(f"\n{YELLOW} [→] 服务已停止{RESET}")
else:
print(f"\n{GREEN}╔══════════════════════════════════════════════════╗{RESET}")
print(f"{GREEN}║ ✓ 搜索完成! CSV: {csv_path}{RESET}")
print(f"{GREEN}╚══════════════════════════════════════════════════╝{RESET}\n")
if __name__ == "__main__":
main()