Copy disabled (too large)
Download .txt
Showing preview only (67,686K chars total). Download the full file to get everything.
Repository: wechat-article/wechat-article-exporter
Branch: master
Commit: 9322fe19ac89
Files: 222
Total size: 64.5 MB
Directory structure:
gitextract_6nyqcz44/
├── .dockerignore
├── .env.example
├── .github/
│ └── workflows/
│ └── docker.yml
├── .gitignore
├── .prettierrc
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── apis/
│ └── index.ts
├── app.vue
├── biome.json
├── components/
│ ├── ButtonGroup.vue
│ ├── Placeholder.vue
│ ├── ProxyMetrics.vue
│ ├── RadioGroup.vue
│ ├── StorageUsage.vue
│ ├── api/
│ │ ├── CodeSegment.vue
│ │ ├── DebugModal.vue
│ │ ├── Document.vue
│ │ └── Summary.vue
│ ├── base/
│ │ ├── DatePicker.vue
│ │ ├── ExternalLink.vue
│ │ └── Tag.vue
│ ├── dashboard/
│ │ ├── Actions.vue
│ │ ├── AuthPopoverPanel.vue
│ │ ├── BottomPanel.vue
│ │ ├── NavMenus.vue
│ │ └── SideBar.vue
│ ├── global/
│ │ ├── CredentialsDialog.vue
│ │ └── SearchAccountDialog.vue
│ ├── grid/
│ │ ├── AccountActions.vue
│ │ ├── Album.vue
│ │ ├── ArticleActions.vue
│ │ ├── BooleanCellRenderer.vue
│ │ ├── CoverTooltip.vue
│ │ ├── LoadProgress.vue
│ │ ├── Loading.vue
│ │ ├── NoRows.vue
│ │ └── StatusBar.vue
│ ├── modal/
│ │ ├── Confirm.vue
│ │ ├── Login.vue
│ │ └── QQGroup.vue
│ ├── preview/
│ │ ├── Article.vue
│ │ └── HtmlRenderer.vue
│ ├── search/
│ │ ├── AccountForm.vue
│ │ └── Article.vue
│ ├── selector/
│ │ ├── AccountSelectorForAlbum.vue
│ │ └── AccountSelectorForArticle.vue
│ └── setting/
│ ├── Display.vue
│ ├── Export.vue
│ ├── Misc.vue
│ └── Proxy.vue
├── composables/
│ ├── toast.ts
│ ├── useAccountEventBus.ts
│ ├── useBatchDownload.ts
│ ├── useDownloader.ts
│ ├── useExporter.ts
│ ├── useLoginAccount.ts
│ ├── useLoginCheck.ts
│ ├── usePreferences.ts
│ └── useSyncDeadline.ts
├── config/
│ ├── index.ts
│ ├── proxy.txt
│ ├── public-apis.ts
│ ├── public-proxy.ts
│ └── shared-grid-options.ts
├── error.vue
├── nuxt.config.ts
├── package.json
├── pages/
│ ├── dashboard/
│ │ ├── account.vue
│ │ ├── album.vue
│ │ ├── api.vue
│ │ ├── article.vue
│ │ ├── proxy.vue
│ │ ├── settings.vue
│ │ ├── single.vue
│ │ └── support.vue
│ ├── dashboard.vue
│ ├── dev/
│ │ ├── discuss.vue
│ │ ├── example-error.vue
│ │ ├── markdown.vue
│ │ └── youtube-channel-id.vue
│ └── index.vue
├── public/
│ ├── custom-elements/
│ │ └── mp-common-mpaudio.js
│ ├── plugins/
│ │ └── credential.py
│ └── vendors/
│ └── html-docx-js@0.3.1/
│ └── html-docx.js
├── samples/
│ ├── aboutbiz/
│ │ ├── biz-MjM5ODMxNzE2NQ==.html
│ │ ├── biz-MjM5OTM0OTE1Mg==.html
│ │ ├── biz-MzAxODU1ODg2Mg==.html
│ │ ├── biz-MzI0Nzg2MTExMA==.html
│ │ ├── biz-MzI4NjAxNjY4Nw==.html
│ │ ├── biz-MzIxMTgzNjE5MA==.html
│ │ └── biz-Mzg3OTYzMDkzMg==.html
│ ├── author/
│ │ ├── 01.html
│ │ └── 包含author信息.md
│ ├── 作者已删除/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── 05.html
│ │ ├── 06.html
│ │ └── 作者已删除.md
│ ├── 内容违规/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ └── 内容违规.md
│ ├── 图片分享/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── 05.html
│ │ └── 图片分享.md
│ ├── 文本分享/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── c01.html
│ │ ├── c02.html
│ │ ├── c03.html
│ │ ├── c04.html
│ │ ├── c05.html
│ │ └── 文本分享.md
│ ├── 文章分享/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ └── 文章分享.md
│ ├── 普通图文/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── c01.html
│ │ └── 普通图文.md
│ └── 该内容暂时无法查看/
│ ├── 01.html
│ └── 无法查看.md
├── sentry.client.config.ts
├── server/
│ ├── api/
│ │ ├── _debug.get.ts
│ │ ├── public/
│ │ │ ├── beta/
│ │ │ │ ├── aboutbiz.get.ts
│ │ │ │ └── authorinfo.get.ts
│ │ │ └── v1/
│ │ │ ├── account.get.ts
│ │ │ ├── accountbyurl.get.ts
│ │ │ ├── article.get.ts
│ │ │ ├── authkey.get.ts
│ │ │ └── download.get.ts
│ │ └── web/
│ │ ├── login/
│ │ │ ├── bizlogin.post.ts
│ │ │ ├── getqrcode.get.ts
│ │ │ ├── scan.get.ts
│ │ │ └── session/
│ │ │ └── [sid].post.ts
│ │ ├── misc/
│ │ │ ├── accountname.get.ts
│ │ │ ├── appmsgalbum.get.ts
│ │ │ ├── comment.get.ts
│ │ │ └── current-ip.get.ts
│ │ ├── mp/
│ │ │ ├── appmsgpublish.get.ts
│ │ │ ├── info.get.ts
│ │ │ ├── logout.get.ts
│ │ │ ├── profile_ext_getmsg.get.ts
│ │ │ ├── searchbiz.get.ts
│ │ │ └── searchbyurl.get.ts
│ │ └── worker/
│ │ ├── README.md
│ │ ├── blocked-ip-list.get.ts
│ │ ├── overview-metrics.get.ts
│ │ └── security-top-n.get.ts
│ ├── kv/
│ │ └── cookie.ts
│ ├── tsconfig.json
│ ├── types.d.ts
│ └── utils/
│ ├── CookieStore.ts
│ ├── fetch_external.ts
│ ├── logger.ts
│ └── proxy-request.ts
├── shared/
│ ├── readme.md
│ └── utils/
│ ├── helpers.ts
│ ├── html.ts
│ ├── index.ts
│ ├── renderer.ts
│ └── request.ts
├── store/
│ └── v2/
│ ├── article.ts
│ ├── assets.ts
│ ├── comment.ts
│ ├── comment_reply.ts
│ ├── db.ts
│ ├── debug.ts
│ ├── html.ts
│ ├── index.ts
│ ├── info.ts
│ ├── metadata.ts
│ ├── resource-map.ts
│ └── resource.ts
├── style.css
├── tailwind.config.js
├── test/
│ ├── common.ts
│ ├── normalize_html.ts
│ ├── parse_cgi_data.ts
│ ├── render_html_from_cgi_data.ts
│ └── validate_html_content.ts
├── todos.md
├── tsconfig.json
├── types/
│ ├── account.d.ts
│ ├── album.d.ts
│ ├── article.d.ts
│ ├── comment.d.ts
│ ├── credential.d.ts
│ ├── env.d.ts
│ ├── preferences.d.ts
│ ├── profile_getmsg.d.ts
│ ├── proxy.d.ts
│ ├── types.d.ts
│ └── video.d.ts
└── utils/
├── album.ts
├── comment.ts
├── download/
│ ├── BaseDownloader.ts
│ ├── Downloader.ts
│ ├── Exporter.ts
│ ├── ProxyManager.ts
│ ├── constants.ts
│ └── types.d.ts
├── exporter.ts
├── grid.ts
├── index.ts
├── pool.ts
└── readme.md
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
### NuxtJS template
# Generated dirs
dist
.nuxt
.nuxt-*
.output
.gen
.yarn/cache # Yarn 缓存
yarn-error.log
# Node dependencies
node_modules
# System files
*.log
.git
.github
.env*
.DS_Store
.idea
.data
design
docs
nuxt-options.json
# wrangler
.wrangler
================================================
FILE: .env.example
================================================
# 调试微信代理请求 (仅开发环境(development)支持)
NUXT_DEBUG_MP_REQUEST=false
# AG-Grid 企业版授权
NUXT_AGGRID_LICENSE=
# KV绑定(本地/docker)
NITRO_KV_DRIVER=fs
NITRO_KV_BASE=.data/kv
# KV绑定(cloudflare)
#NITRO_KV_DRIVER=cloudflare-kv-binding
DEBUG_KEY=
================================================
FILE: .github/workflows/docker.yml
================================================
name: Build and Push Multi-Arch Docker Image
# 触发条件:推送 v* 标签时(如 git tag v1.1.0 && git push --tags)
on:
push:
tags:
- 'v*'
# 权限:允许 Actions 推送到 GHCR
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest # AMD64 主机,用于交叉构建 ARM
steps:
# 检出代码
- name: Checkout
uses: actions/checkout@v4
# 设置 Docker Buildx(启用多架构)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 登录 GHCR(使用 GitHub Token)
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 设置 QEMU(用于 ARM 交叉构建)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# 从标签提取版本(匹配 v1.0.0 格式)
- name: Extract version from tag
id: version
run: |
VERSION="${{ github.ref_name }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
# 构建并推送多架构镜像
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile # 你的 Dockerfile 路径
push: true # 直接推送
platforms: linux/amd64,linux/arm64 # 目标架构
tags: |
ghcr.io/wechat-article/wechat-article-exporter:${{ steps.version.outputs.version }}-amd64
ghcr.io/wechat-article/wechat-article-exporter:${{ steps.version.outputs.version }}-arm64
ghcr.io/wechat-article/wechat-article-exporter:${{ steps.version.outputs.version }}
ghcr.io/wechat-article/wechat-article-exporter:latest
build-args: |
VERSION=${{ steps.version.outputs.version }} # 传递到 Dockerfile 的 ARG
cache-from: type=gha # 使用 GitHub Actions 缓存加速
cache-to: type=gha,mode=max
================================================
FILE: .gitignore
================================================
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
debug
design
docs
samples/**/output
# Local env files
.env
.env.*
!.env.example
# wrangler
.wrangler
================================================
FILE: .prettierrc
================================================
{
"arrowParens": "avoid",
"bracketSameLine": false,
"bracketSpacing": true,
"semi": true,
"experimentalTernaries": false,
"singleQuote": true,
"jsxSingleQuote": false,
"jsxBracketSameLine": false,
"quoteProps": "as-needed",
"trailingComma": "es5",
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": false,
"proseWrap": "always",
"insertPragma": false,
"printWidth": 120,
"requirePragma": false,
"tabWidth": 2,
"useTabs": false,
"embeddedLanguageFormatting": "auto"
}
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。
## 项目概述
微信公众号文章批量下载导出工具。基于 Nuxt 3(关闭 SSR,纯客户端 SPA)+ Vue 3 Composition API + Nitro 服务端引擎构建。支持导出 HTML/JSON/Excel/TXT/Markdown/DOCX 格式,其中 HTML 格式可 100% 还原文章排版与样式。
## 常用命令
```bash
# 安装依赖(要求 Node >= 22,yarn 1.22.22 通过 corepack 管理)
corepack enable && corepack prepare yarn@1.22.22 --activate
yarn
# 开发
yarn dev # 启动开发服务器
# 构建与部署
yarn build # 生产构建(输出到 .output/)
yarn preview # 构建 Cloudflare Pages 版本并本地预览
# 代码格式化(Biome 为主要格式化工具,linter 已禁用)
yarn format # biome check --write
# Docker
yarn docker:build
yarn docker:publish
```
## 架构
### 客户端-服务端分离
- **客户端(SPA):** `app.vue` → `pages/dashboard.vue` 为唯一路由页面,所有 UI 均在客户端运行(SSR 已关闭)。
- **服务端(Nitro):** `server/api/` 包含约 25 个接口端点,负责代理转发微信公众平台的 API 请求,处理 CORS 和 Cookie 转发。核心代理逻辑在 `server/utils/proxy-request.ts`。
### 核心数据流
1. 用户通过微信公众平台后台扫码登录认证
2. `apis/index.ts` 定义客户端 API 函数,调用 Nitro 代理端点
3. 文章数据获取后经过过滤,通过 Dexie 缓存到 IndexedDB(`store/v2/db.ts`)
4. 下载调度:`utils/download/Downloader.ts` 使用 `p-queue` 管理并发下载
5. 导出:`utils/download/Exporter.ts` 将文章转换为目标格式(Cheerio 处理 HTML、Turndown 转 Markdown、ExcelJS 生成表格、JSZip 打包)
### 关键目录
- `apis/` — 客户端 API 函数定义(getArticleList、getAccountList 等)
- `composables/` — Vue 3 组合式函数:`useDownloader.ts`(下载调度)、`useExporter.ts`(导出逻辑)、`useBatchDownload.ts`(批量下载管理)
- `store/v2/` — 基于 Dexie 的 IndexedDB 缓存(文章、评论、元数据、资源、HTML 内容)
- `utils/download/` — 核心下载/导出类:`Downloader.ts`、`Exporter.ts`、`BaseDownloader.ts`、`ProxyManager.ts`
- `server/api/web/mp/` — 代理微信公众平台请求的 Nitro 端点
- `server/utils/` — 服务端工具:代理请求处理、Cookie 管理、日志
- `shared/utils/` — 客户端与服务端共享代码(HTML 解析、请求工具函数)
- `config/` — 应用常量、公共 API 端点定义、AG Grid 配置
- `types/` — TypeScript 类型定义(AppMsgEx、AccountInfo、credentials、comments 等)
### UI 技术栈
Nuxt UI v2 + TailwindCSS 提供组件和样式。AG Grid Enterprise 用于文章数据表格。Monaco Editor 用于代码/调试视图。
## 代码规范
- Biome 格式化(非 lint):行宽 120 字符、2 空格缩进、单引号、ES5 尾逗号、带分号
- CSS 使用 4 空格缩进
- Vue 文件:script/style 标签内不额外缩进
- 命名:函数/变量使用 camelCase,组件使用 PascalCase
## 环境变量
复制 `.env.example` 为 `.env`,关键变量:
- `NUXT_AGGRID_LICENSE` — AG Grid 企业版授权密钥
- `NITRO_KV_DRIVER` — 存储驱动(本地/Docker 用 `fs`,Cloudflare 用 `cloudflare-kv-binding`)
- `NITRO_KV_BASE` — KV 数据目录(默认:`.data/kv`)
- `NUXT_DEBUG_MP_REQUEST` — 开启微信代理请求调试(仅开发环境)
- `DEBUG_KEY` — 调试端点认证密钥
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
findsource@proton.me.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
================================================
FILE: CONTRIBUTING.md
================================================
# 贡献指南
感谢您对本项目的兴趣!我们非常欢迎各种形式的贡献,包括但不限于代码、文档、Bug 报告和功能建议。🙌
## 行为准则
本项目遵循 [Contributor Covenant 行为准则](./CODE_OF_CONDUCT.md)。请在所有互动中保持友好和尊重。
## 开发环境搭建
### 克隆仓库
```shell
git clone git@github.com:wechat-article/wechat-article-exporter.git
```
### 安装 NodeJS
> 本项目要求 Node >= 22
按照 Node.js 官方的[安装指南](https://nodejs.org/en/download)进行安装。
### 安装项目依赖
> 本项目使用 yarn@1.22 进行依赖管理
```shell
corepack enable
corepack prepare yarn@1.22.22 --activate
yarn
```
### 本地运行
```shell
yarn dev
```
## 如何贡献
### 1. 报告 Bug 或建议功能
- 先搜索现有 [Issues](https://github.com/wechat-article/wechat-article-exporter/issues),避免重复。
- 如果没有找到,创建一个新 Issue。
- Bug 报告请包含:复现步骤、预期行为、实际行为、环境信息(操作系统、浏览器、版本等)。
- 功能建议请详细描述需求和使用场景。
### 2. 提交代码
请遵循以下流程:
1. Fork 本仓库。
2. 创建特性分支:`git checkout -b feature/你的功能描述` 或 `fix/你的修复描述`。
3. 安装开发依赖并运行项目(参考 开发环境搭建)。
4. 编写代码,确保:
- **仅提交必要文件**。
- 遵循项目代码风格(使用 Prettier 工具)。
- 添加或更新测试用例。
- 通过所有测试:`yarn test`(或你的测试命令)。
5. 提交时使用清晰的 Commit 消息。
6. Push 到你的 Fork 并打开 Pull Request。
- PR 标题和描述要清晰,引用相关 Issue(如 `fixes #123`)。
- **如果是重大更改,请先开 Issue 讨论**。
### 3. 文档或翻译贡献
- 文档请修改 [docs](https://github.com/wechat-article/docs) 项目
- 同样通过 Pull Request 提交
### 4. 代码风格指南
- 代码格式化采用 prettier
- 变量命名采用 camelCase
- import 顺序采用`yarn format`命令格式化
================================================
FILE: Dockerfile
================================================
# 编译层
FROM node:22-alpine AS build-env
# 安装 Yarn (pin a specific Yarn version)
RUN corepack enable
RUN corepack prepare yarn@1.22.22 --activate
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 lock 文件,安装依赖
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production=true && yarn cache clean
# 复制源代码
COPY . .
# 构建 Nuxt 应用(生成 .output 目录)
ENV NODE_ENV=production \
NITRO_KV_DRIVER=fs \
NITRO_KV_BASE=.data/kv
RUN yarn build
# 运行时层
FROM node:22-alpine
ARG VERSION=unknown
# 添加 LABEL 元数据
LABEL maintainer="findsource@proton.me" \
version="${VERSION}" \
description="wechat-article-exporter Docker Image" \
org.opencontainers.image.source="https://github.com/wechat-article/wechat-article-exporter" \
org.opencontainers.image.description="一个在线的微信公众号文章批量下载工具,支持下载阅读量与评论数据,支持私有化部署,通过浏览器进行使用,无需进行安装" \
org.opencontainers.image.licenses="MIT"
# 设置工作目录
WORKDIR /app
# 复制构建输出
COPY --from=build-env /app/.output ./
# 创建 KV 存储目录并设置权限(以 root 运行,确保 node 用户可写)
RUN mkdir -p .data/kv && chown -R node:node /app
# 创建非 root 用户(使用内置 node 用户)
USER node
# 暴露端口
EXPOSE 3000
# 设置环境变量:生产模式,监听所有接口
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
# 启动命令:运行 Nitro 生成的服务器
ENTRYPOINT ["node", "server/index.mjs"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 Jock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<p align="center">
<img src="./assets/logo.svg" alt="Logo">
</p>
# wechat-article-exporter
![GitHub stars]
![GitHub forks]
![GitHub License]
![Package Version]
一款在线的 **微信公众号文章批量下载** 工具,支持导出阅读量与评论数据,无需搭建任何环境,可通过 [在线网站] 使用,同时也支持 docker 私有化部署和 Cloudflare 部署。
支持下载各种文件格式,其中 HTML 格式可100%还原文章排版与样式。
交流群(QQ): `991482155`
## :bell: 重要告知:项目域名调整
项目域名调整如下:
| | 下载站 | 文档站 |
|-----|--------------------------------|----------------------------|
| 调整后 | https://down.mptext.top | https://docs.mptext.top |
| 调整前 | https://exporter.wxdown.online | https://docs.wxdown.online |
具体细节可以查看 [这里](https://docs.mptext.top/misc/domain.html)。
## :books: 如何使用?
该工具的使用教程已移至 [文档站点](https://docs.mptext.top)。
## :dart: 特性
- [x] 搜索公众号,支持关键字搜索
- [x] 支持导出 html/json/excel/txt/md/docx 格式(html 格式打包了图片和样式文件,能够保证100%还原文章样式)
- [x] 缓存文章列表数据,减少接口请求次数
- [x] 支持文章过滤,包括作者、标题、发布时间、原创标识、所属合集等
- [x] 支持合集下载
- [x] 支持图片分享消息
- [x] 支持视频分享消息
- [x] 支持导出评论、评论回复、阅读量、转发量等数据 (需要抓包获取 credentials 信息,[查看操作步骤](https://docs.mptext.top/advanced/wxdown-service.html))
- [x] 支持 Docker 部署
- [x] 支持 Cloudflare 部署
- [x] 开放 API 接口
## :heart: 感谢
- 感谢 [Deno Deploy]、[Cloudflare Workers] 提供免费托管服务
- 感谢 [WeChat_Article] 项目提供原理思路
## :star: 支持
如果你觉得本项目帮助到了你,请给作者一个免费的 Star,感谢你的支持!
## :bulb: 原理
在公众号后台写文章时支持搜索其他公众号的文章功能,以此来实现抓取指定公众号所有文章的目的。
## :memo: 许可
MIT
## :red_circle: 声明
本程序承诺,不会利用您扫码登录的公众号进行任何形式的私有爬虫,也就是说不存在把你的账号作为公共账号为别人爬取文章的行为,也不存在类似账号池的东西。
您的公众号只会服务于您自己的抓取文章的目的。
通过本程序获取的公众号文章内容,版权归文章原作者所有,请合理使用。若发现侵权行为,请联系我们处理。
## :chart_with_upwards_trend: Star 历史
[![Star History Chart]][Star History Chart Link]
<!-- Definitions -->
[GitHub stars]: https://img.shields.io/github/stars/wechat-article/wechat-article-exporter?style=social&label=Star&style=plastic
[GitHub forks]: https://img.shields.io/github/forks/wechat-article/wechat-article-exporter?style=social&label=Fork&style=plastic
[GitHub License]: https://img.shields.io/github/license/wechat-article/wechat-article-exporter?label=License
[Package Version]: https://img.shields.io/github/package-json/v/wechat-article/wechat-article-exporter
[Deno Deploy]: https://deno.com/deploy
[Cloudflare Workers]: https://workers.cloudflare.com
[Wechat_Article]: https://github.com/1061700625/WeChat_Article
[Star History Chart]: https://api.star-history.com/svg?repos=wechat-article/wechat-article-exporter&type=Timeline
[Star History Chart Link]: https://star-history.com/#wechat-article/wechat-article-exporter&Timeline
[在线网站]: https://down.mptext.top
================================================
FILE: apis/index.ts
================================================
import { request } from '#shared/utils/request';
import { ACCOUNT_LIST_PAGE_SIZE, ARTICLE_LIST_PAGE_SIZE } from '~/config';
import { updateArticleCache } from '~/store/v2/article';
import { type MpAccount, updateLastUpdateTime } from '~/store/v2/info';
import type { CommentResponse } from '~/types/comment';
import type { ParsedCredential } from '~/types/credential';
import type { ParsedProfileGetMsg, ProfileGetMsgResponse } from '~/types/profile_getmsg';
import type {
AccountInfo,
AppMsgEx,
AppMsgPublishResponse,
PublishInfo,
PublishPage,
SearchBizResponse,
} from '~/types/types';
const loginAccount = useLoginAccount();
const credentials = useLocalStorage<ParsedCredential[]>('auto-detect-credentials:credentials', []);
/**
* 获取文章列表
* @param account
* @param begin
* @param keyword
* @return [文章列表, 是否加载完毕, 文章总数]
*/
export async function getArticleList(
account: MpAccount,
begin = 0,
keyword = ''
): Promise<[AppMsgEx[], boolean, number]> {
const resp = await request<AppMsgPublishResponse>('/api/web/mp/appmsgpublish', {
query: {
id: account.fakeid,
begin: begin,
size: ARTICLE_LIST_PAGE_SIZE,
keyword: keyword,
},
});
if (resp.base_resp.ret === 0) {
const publish_page: PublishPage = JSON.parse(resp.publish_page);
const publish_list = publish_page.publish_list.filter(item => !!item.publish_info);
// 返回的文章数量为0就表示已加载完毕
const isCompleted = publish_list.length === 0;
// 更新缓存,注意带有关键字搜索的结果不能写入缓存
if (!keyword) {
try {
await updateArticleCache(account, publish_page);
if (begin === 0) {
await updateLastUpdateTime(account.fakeid);
}
} catch (e) {
console.error('写入文章缓存失败:', e);
}
}
const articles = publish_list.flatMap(item => {
const publish_info: PublishInfo = JSON.parse(item.publish_info);
return publish_info.appmsgex;
});
return [articles, isCompleted, publish_page.total_count];
} else if (resp.base_resp.ret === 200003) {
loginAccount.value = null;
throw new Error('session expired');
} else {
throw new Error(`${resp.base_resp.ret}:${resp.base_resp.err_msg}`);
}
}
/**
* 获取公众号列表
* @param begin
* @param keyword
*/
export async function getAccountList(begin = 0, keyword = ''): Promise<[AccountInfo[], boolean]> {
const resp = await request<SearchBizResponse>('/api/web/mp/searchbiz', {
query: {
begin: begin,
size: ACCOUNT_LIST_PAGE_SIZE,
keyword: keyword,
},
});
if (resp.base_resp.ret === 0) {
// 公众号判断是否结束的逻辑与文章不太一样
// 当第一页的结果就少于5个则结束,否则只有当搜索结果为空才表示结束
const isCompleted = begin === 0 ? resp.total < ACCOUNT_LIST_PAGE_SIZE : resp.total === 0;
return [resp.list, isCompleted];
} else if (resp.base_resp.ret === 200003) {
loginAccount.value = null;
throw new Error('session expired');
} else {
throw new Error(`${resp.base_resp.ret}:${resp.base_resp.err_msg}`);
}
}
/**
* 获取评论
* @param commentId
*/
export async function getComment(commentId: string) {
try {
// 本地设置的 credentials
const credentials = JSON.parse(window.localStorage.getItem('credentials')!);
if (!credentials || !credentials.__biz || !credentials.pass_ticket || !credentials.key || !credentials.uin) {
console.warn('credentials not set');
return null;
}
const response = await request<CommentResponse>('/api/web/misc/comment', {
query: {
comment_id: commentId,
...credentials,
},
});
if (response.base_resp.ret === 0) {
return response;
} else {
return null;
}
} catch (e) {
console.warn('credentials parse error', e);
return null;
}
}
/**
* 获取公众号文章列表
* @description 该接口采用微信接口,而非公众号平台接口,因此需要先获取 Credentials
* @param fakeid
* @param begin
*/
export async function getArticleListWithCredential(fakeid: string, begin = 0) {
const targetCredential = credentials.value.find(item => item.biz === fakeid);
if (!targetCredential) {
throw new Error('目标公众号的 Credential 未设置');
}
const resp = await request<ProfileGetMsgResponse>('/api/web/mp/profile_ext_getmsg', {
query: {
id: fakeid,
begin: begin,
size: 10,
uin: targetCredential.uin,
key: targetCredential.key,
pass_ticket: targetCredential.pass_ticket,
},
});
if (resp.ret === 0) {
return JSON.parse(resp.general_msg_list) as ParsedProfileGetMsg[];
} else {
throw new Error(`${resp.ret}:${resp.errmsg}`);
}
}
================================================
FILE: app.vue
================================================
<template>
<div :class="isDev ? 'debug-screens' : ''" class="flex flex-col h-screen">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<UNotifications />
<UModals />
</div>
</template>
<script setup lang="ts">
import { ModuleRegistry } from 'ag-grid-community';
import { AllEnterpriseModule, LicenseManager } from 'ag-grid-enterprise';
import { isDev } from '~/config';
import { isChromeBrowser } from '~/utils';
const runtimeConfig = useRuntimeConfig();
ModuleRegistry.registerModules([AllEnterpriseModule]);
LicenseManager.setLicenseKey(runtimeConfig.public.aggridLicense);
if (!isChromeBrowser()) {
alert('为了更好的用户体验,推荐使用 Chrome 浏览器。');
}
</script>
<style>
@import 'style.css';
</style>
================================================
FILE: biome.json
================================================
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!public/vendors", "!samples"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 120,
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"expand": "auto",
"useEditorconfig": true
},
"linter": {
"enabled": false
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "asNeeded",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"html": {
"formatter": {
"indentScriptAndStyle": false,
"selfCloseVoidElements": "always"
}
},
"css": {
"formatter": {
"indentWidth": 4
}
}
}
================================================
FILE: components/ButtonGroup.vue
================================================
<template>
<UDropdown :items="items" :popper="{ placement: 'bottom-start' }">
<slot>
<UButton color="white" label="导出" trailing-icon="i-heroicons-chevron-down-20-solid" />
</slot>
</UDropdown>
</template>
<script setup lang="ts">
const emit = defineEmits();
interface Item {
label: string;
event: string;
disabled?: boolean;
}
interface Props {
items: Item[];
}
const props = defineProps<Props>();
const items = [
props.items.map(item => ({
label: item.label,
click() {
emit(item.event);
},
disabled: item.disabled,
})),
];
</script>
================================================
FILE: components/Placeholder.vue
================================================
<template>
<div
class="relative overflow-hidden rounded border border-dashed border-gray-400 dark:border-gray-500 opacity-75 px-4 flex items-center justify-center"
>
<svg class="absolute inset-0 h-full w-full stroke-gray-900/10 dark:stroke-white/10" fill="none">
<defs>
<pattern
id="pattern-5c1e4f0e-62d5-498b-8ff0-cf77bb448c8e"
x="0"
y="0"
width="10"
height="10"
patternUnits="userSpaceOnUse"
>
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3"></path>
</pattern>
</defs>
<rect stroke="none" fill="url(#pattern-5c1e4f0e-62d5-498b-8ff0-cf77bb448c8e)" width="100%" height="100%"></rect>
</svg>
</div>
</template>
================================================
FILE: components/ProxyMetrics.vue
================================================
<template>
<div class="flex flex-wrap gap-x-10 gap-y-5">
<div
v-for="account in accountMetrics"
:key="account.name"
class="relative w-full max-w-2xl border p-5 rounded-md hover:shadow"
>
<h3 class="text-xl text-gray-600 font-mono mb-3" :title="account.name">节点: {{ account.domain }}</h3>
<UMeter v-if="account.metric" :value="account.metric.dailyRequests" :max="100_000" color="orange">
<template #indicator>
<div class="flex justify-between items-center text-gray-400">
<span>今日请求量</span>
<p>
<span class="text-base text-green-500 font-semibold font-mono">
{{ Math.round((Math.min(account.metric.dailyRequests, 100_000) / 100_000) * 100) }}%
</span>
<span class="font-mono text-xs">
({{ account.metric === null ? '未知' : account.metric.dailyRequests.toLocaleString('en-US') }}/{{
(100_000).toLocaleString('en-US')
}})
</span>
</p>
</div>
</template>
</UMeter>
<span v-else>状态未知</span>
<div class="flex items-center gap-3 absolute right-5 top-5">
<div class="size-5">
<UIcon
v-if="account.copied"
name="i-lucide:check"
class="size-5 text-gray-500 hover:text-gray-400 cursor-pointer"
/>
<UTooltip v-else text="复制节点地址">
<UIcon
name="i-lucide:copy"
class="size-5 text-gray-500 hover:text-gray-400 cursor-pointer"
@click="copyAddress(account)"
/>
</UTooltip>
</div>
</div>
<div class="mt-5">
<header class="flex justify-between items-center mb-2">
<h3 class="text-base text-gray-500">统计信息</h3>
<div class="size-5">
<UIcon
v-if="account.fetchAnalyticsLoading"
name="i-lucide:loader"
class="size-5 text-gray-400 animate-spin"
/>
<UTooltip v-else text="节点使用信息">
<UIcon
name="i-lucide:activity"
class="size-5 text-gray-500 hover:text-gray-400 cursor-pointer"
@click="nodeAnalytics(account)"
/>
</UTooltip>
</div>
</header>
<div
v-for="item in account.topClientIPs"
:key="item.clientIP"
class="relative flex justify-between items-center text-gray-400 hover:bg-gray-100 my-2 px-2 py-1 rounded overflow-hidden"
>
<!-- 灰色背景条(全宽) -->
<div class="absolute inset-0 bg-gray-100 rounded"></div>
<!-- 蓝色进度条(根据 count / total 动态宽度) -->
<div
:style="{ width: account.total ? (item.count / account.total) * 100 + '%' : '0%' }"
class="absolute inset-y-0 left-0 bg-blue-700 rounded-l"
></div>
<!-- IP 和计数文字(在最上层) -->
<p class="relative z-10 font-mono text-sm">{{ item.clientIP }}</p>
<p class="relative z-10 font-mono text-sm">
{{ item.count > 1000 ? (item.count / 1000).toFixed(2) + 'k' : item.count }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { request } from '#shared/utils/request';
import type { AccountMetric } from '~/types/proxy';
interface Props {
data: AccountMetric[];
}
interface AccountMetricWithExtra extends AccountMetric {
copied: boolean;
fetchAnalyticsLoading: boolean;
topClientIPs: Security[];
total: number;
}
interface Security {
clientIP: string;
count: number;
}
const props = defineProps<Props>();
const accountMetrics: AccountMetricWithExtra[] = reactive(
props.data.map((account: AccountMetric) => ({
...account,
copied: false,
fetchAnalyticsLoading: false,
topClientIPs: [],
total: 0,
}))
);
watch(
() => props.data,
() => {
Object.assign(
accountMetrics,
props.data.map((account: AccountMetric) => ({
...account,
copied: false,
fetchAnalyticsLoading: false,
topClientIPs: [],
total: 0,
}))
);
}
);
function copyAddress(account: AccountMetricWithExtra) {
let result: string[] = [];
for (let i = 0; i < 16; i++) {
result.push(`https://${('0' + i).slice(-2)}${account.domain.replace(/^\*/, '')}`);
}
navigator.clipboard.writeText(result.join('\n'));
account.copied = true;
setTimeout(() => {
account.copied = false;
}, 1000);
}
async function nodeAnalytics(account: AccountMetricWithExtra) {
account.fetchAnalyticsLoading = true;
const resp = await request('/api/web/worker/security-top-n', {
method: 'GET',
query: {
name: account.name,
},
}).finally(() => {
account.fetchAnalyticsLoading = false;
});
account.topClientIPs = resp.topClientIPs;
account.total = resp.total;
}
</script>
================================================
FILE: components/RadioGroup.vue
================================================
<template>
<div class="flex items-baseline">
<div class="space-x-2 flex text-sm">
<label v-for="option in options" :key="option.label" @click="value = option.value">
<input class="sr-only peer" :name="name" type="radio" :value="option.value" :checked="value === option.value" />
<span
class="px-3 py-2 rounded-lg flex items-center justify-center text-slate-700 peer-checked:font-semibold peer-checked:bg-slate-900 peer-checked:text-white"
>
{{ option.label }}
</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
name: string;
options: Array<{ label: string; value: string }>;
}
defineProps<Props>();
const value = defineModel<string>();
</script>
================================================
FILE: components/StorageUsage.vue
================================================
<script setup lang="ts">
const usage = ref('');
async function init() {
const storageUsage = await navigator.storage.estimate();
const bytes = storageUsage.usage!;
if (bytes < 1000) {
usage.value = `${bytes} B`;
} else if (bytes < 1000 ** 2) {
usage.value = `${(bytes / 1000).toFixed(0)} kB`;
} else if (bytes < 1000 ** 3) {
usage.value = `${(bytes / 1000 ** 2).toFixed(1)} M`;
} else {
usage.value = `${(bytes / 1000 ** 3).toFixed(1)} G`;
}
}
let timer: number;
onMounted(() => {
timer = window.setInterval(() => {
init();
}, 1000);
});
onUnmounted(() => {
window.clearInterval(timer);
});
</script>
<template>
<p class="text-sm">
本地数据库占用约为 <span class="text-rose-500">{{ usage }}</span>
</p>
</template>
================================================
FILE: components/api/CodeSegment.vue
================================================
<script setup lang="ts">
import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import xml from 'highlight.js/lib/languages/xml';
import 'highlight.js/styles/stackoverflow-dark.css';
import { Check, Copy } from 'lucide-vue-next';
hljs.registerLanguage('json', json);
hljs.registerLanguage('xml', xml);
interface Props {
code: Record<string, any> | string;
lang: 'json' | 'xml' | 'text';
}
const props = defineProps<Props>();
const code = computed(() => {
if (typeof props.code === 'string') {
return props.code;
} else if (typeof props.code === 'object') {
return JSON.stringify(props.code, null, 2);
} else {
throw new Error(`Unknown code: ${JSON.stringify(props.code)}`);
}
});
const hlCode = computed(() => {
if (props.lang === 'text') {
return `<span class="text-xl">${code.value}</span>`;
}
return hljs.highlight(code.value, { language: props.lang }).value;
});
const copied = ref(false);
function copy() {
navigator.clipboard.writeText(code.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 1000);
}
</script>
<template>
<div class="relative">
<pre
class="bg-black text-gray-400 p-2 rounded overflow-scroll no-scrollbar"
><Check v-if="copied" class="absolute right-3 top-3 size-5"/><Copy v-else class="absolute right-3 top-3 size-5 text-gray-500 hover:text-gray-400 cursor-pointer" @click="copy"/><span v-html="hlCode"></span></pre>
</div>
</template>
================================================
FILE: components/api/DebugModal.vue
================================================
<script setup lang="ts">
import type { FormError } from '#ui/types';
import CodeSegment from '~/components/api/CodeSegment.vue';
import { apis } from '~/config/public-apis';
interface Props {
initialSelected: string;
}
const props = defineProps<Props>();
const isOpen = ref(false);
const selectedApi = ref(apis[0]);
const payload: Ref<Record<string, any>> = ref({});
const host = window.location.protocol + '//' + window.location.host;
function onOpen() {
isOpen.value = true;
selectedApi.value = apis.find(api => api.name === props.initialSelected)!;
}
function apiChange() {
payload.value = {};
}
function isEmpty(key: string, obj: Record<string, any>): boolean {
if (!obj.hasOwnProperty(key)) {
return true;
}
let value = obj[key];
if (typeof value === 'string') {
value = value.trim();
}
return value === undefined || value === null || value === '';
}
function validate(state: Record<string, any>): FormError[] {
const errors: FormError[] = [];
selectedApi.value.params.forEach(param => {
if (param.required && isEmpty(param.name, state)) {
errors.push({ path: param.name, message: param.name + '不能为空' });
}
if (!isEmpty(param.name, state) && param.type === 'Int') {
if (Number.isNaN(parseInt(state[param.name]))) {
errors.push({ path: param.name, message: param.name + '格式不正确' });
} else if (parseInt(state[param.name]) < 0) {
errors.push({ path: param.name, message: param.name + '不得小于0' });
}
}
});
return errors;
}
const resp = ref<Record<string, any> | null | string>(null);
const hasResponse = computed(() => {
return typeof resp.value === 'string' || (typeof resp.value === 'object' && resp.value);
});
const btnLoading = ref(false);
function submit() {
const params = toRaw(payload.value);
let url = selectedApi.value.url;
if (selectedApi.value.method === 'GET') {
url += '?' + new URLSearchParams(params).toString();
}
btnLoading.value = true;
resp.value = null;
fetch(url, {
method: selectedApi.value.method,
})
.then(resp => {
if (resp.headers.get('content-type') === 'application/json') {
return resp.json();
} else {
return resp.text();
}
})
.then(data => {
resp.value = data;
})
.finally(() => {
btnLoading.value = false;
});
}
</script>
<template>
<div>
<UTooltip text="在线调试" :popper="{ placement: 'top' }">
<UButton color="blue" variant="ghost" square @click="onOpen" icon="i-lucide:bug-play"></UButton>
</UTooltip>
<USlideover v-model="isOpen" :ui="{ width: 'max-w-[800px]' }">
<UCard
class="flex flex-col flex-1"
:ui="{
body: { base: 'overflow-y-scroll h-[calc(100vh-72px)]' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<h1 class="font-semibold text-2xl">在线接口调试工具</h1>
</template>
<div class="space-y-5">
<div class="flex items-center gap-3">
<span>选择接口: </span>
<USelectMenu
class="flex-1"
v-model="selectedApi"
:options="apis"
option-attribute="name"
@change="apiChange"
>
<template #label>
<span
:class="[
selectedApi.method === 'GET' ? 'bg-green-400' : 'bg-fuchsia-400',
'inline-block size-2 flex-shrink-0 rounded-full',
]"
aria-hidden="true"
/>
<span class="font-medium">{{ selectedApi.name }}</span>
</template>
<template #option="{ option: api }">
<div>
<p class="font-medium">
<span
:class="[
api.method === 'GET' ? 'bg-green-400' : 'bg-fuchsia-400',
'inline-block size-2 mr-2 flex-shrink-0 rounded-full',
]"
aria-hidden="true"
/>
<span>{{ api.name }}</span>
</p>
<p class="text-gray-400 font-mono">{{ api.description }}</p>
</div>
</template>
</USelectMenu>
</div>
<div class="space-y-5">
<div>
<p class="font-semibold mb-2">请求URL:</p>
<p class="font-mono border p-2 rounded-md">
<span class="text-gray-400">{{ host }}</span>
<span class="font-semibold">{{ selectedApi.url }}</span>
</p>
</div>
<div>
<p class="font-semibold mb-2">请求方式:</p>
<p class="font-mono border p-2 rounded-md">{{ selectedApi.method }}</p>
</div>
<div>
<p class="font-semibold mb-2">参数:</p>
<UForm :state="payload" :validate="validate" @submit="submit" class="space-y-3">
<UFormGroup
v-for="p in selectedApi.params"
:key="p.name"
:label="p.name"
:name="p.name"
:required="p.required"
>
<UInput
v-model="payload[p.name]"
:type="p.type === 'Int' ? 'number' : 'text'"
:placeholder="p.label + (p.remark ? ',' + p.remark : '')"
/>
</UFormGroup>
<UButton type="submit" color="black" class="px-5" :loading="btnLoading">提交</UButton>
</UForm>
</div>
</div>
<div v-if="hasResponse">
<h3 class="font-bold text-2xl">响应</h3>
<CodeSegment :code="resp!" :lang="typeof resp === 'object' ? 'json' : 'xml'" />
</div>
</div>
</UCard>
</USlideover>
</div>
</template>
================================================
FILE: components/api/Document.vue
================================================
<script setup lang="ts">
import CodeSegment from '~/components/api/CodeSegment.vue';
interface TParam {
name: string;
location: string;
label: string;
required: boolean;
default: string;
type: string;
remark: string;
}
interface Props {
index: number;
name: string;
description: string;
url: string;
method: string;
params: TParam[];
responseSample: any;
remark?: string;
}
defineProps<Props>();
const open = ref(false);
const host = window.location.protocol + '//' + window.location.host;
</script>
<template>
<div class="space-y-5">
<h2 class="flex items-center space-x-3 text-2xl font-semibold font-serif py-2">
<span>{{ index }}. {{ name }}</span>
<ApiDebugModal :initial-selected="name" />
</h2>
<div>
<p class="font-semibold mb-2">简要描述</p>
<p class="font-serif">{{ description }}</p>
</div>
<div v-if="remark">
<p class="font-semibold mb-2">备注:</p>
<p class="text-rose-500">{{ remark }}</p>
</div>
<div>
<p class="font-semibold mb-2">请求URL:</p>
<p class="font-mono border p-2 rounded-md">
<span class="text-gray-400">{{ host }}</span>
<span class="font-semibold">{{ url }}</span>
</p>
</div>
<div>
<p class="font-semibold mb-2">请求方式:</p>
<p class="font-mono border p-2 rounded-md">{{ method }}</p>
</div>
<div>
<p class="font-semibold mb-2">参数:</p>
<div class="border rounded-md overflow-hidden">
<table class="font-mono">
<thead>
<tr>
<th>参数名</th>
<th>参数位置</th>
<th>强制</th>
<th>默认值</th>
<th>类型</th>
<th>说明</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="p in params" :key="p.name">
<td>{{ p.name }}</td>
<td>{{ p.location }}</td>
<td>{{ p.required ? '是' : '否' }}</td>
<td>{{ p.default }}</td>
<td>{{ p.type }}</td>
<td>{{ p.label }}</td>
<td>{{ p.remark }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<p class="font-semibold flex items-center mb-2">
<span class="mr-3">返回示例:</span>
<UToggle v-model="open" color="blue" on-icon="i-heroicons:eye" off-icon="i-heroicons:eye-slash" />
</p>
<CodeSegment v-if="open" :code="responseSample" lang="json" />
</div>
</div>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #e5e7eb;
padding: 8px;
text-align: center;
}
thead {
background-color: #00005506;
}
tr:nth-child(even) {
background-color: #00005506;
}
</style>
================================================
FILE: components/api/Summary.vue
================================================
<script setup lang="ts">
import { sleep } from '#shared/utils/helpers';
import { request } from '#shared/utils/request';
import CodeSegment from '~/components/api/CodeSegment.vue';
import toastFactory from '~/composables/toast';
import type { GetAuthKeyResult } from '~/types/types';
const toast = toastFactory();
const loading = ref(false);
const authKey = ref('');
async function getAuthKey() {
loading.value = true;
try {
await sleep(1000);
const resp = await request<GetAuthKeyResult>(`/api/public/v1/authkey`);
if (resp.code === 0) {
authKey.value = resp.data;
} else {
toast.error('获取密钥失败', resp.msg);
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<p>
为了方便第三方开发人员进行个性化定制,本网站将其主要功能(包括但不限于公众号查询、历史文章列表查询、文章下载等)提供
API 以供接入。
</p>
<p class="text-rose-500 font-medium mt-3">
注意:目前接入 API 免费,后续会根据实际情况动态调整,不排除会改为收费模式。
</p>
<p class="text-rose-500 font-medium mt-1">如果你的调用量比较大的话,推荐进行私有部署。</p>
<UAlert class="mt-10 mb-3">
<template #title>
<h3 class="font-medium text-xl flex items-center space-x-2">
<UIcon name="i-lucide:key-square" />
<span>密钥</span>
</h3>
</template>
<template #description>
<ol class="list-decimal pl-5 text-base">
<!-- <li>-->
<!-- <p>-->
<!-- 由于微信公众号本身的限制,密钥有效期最长为 4 天,且只能通过-->
<!-- <span class="text-rose-500 font-medium">微信扫码</span> 获取。-->
<!-- </p>-->
<!-- </li>-->
<li>
<p>以下所有 <code>API</code> 如无特殊说明,均需要携带密钥进行调用。密钥可通过以下两种方式传输:</p>
<p>a. 通过自定义请求头 <code class="text-rose-500 font-medium font-mono">X-Auth-Key</code></p>
<p>b. 通过 name 为 <code class="text-rose-500 font-medium font-mono">auth-key</code> 的 Cookie</p>
</li>
<li>
<p>
<span
>调用 API 的密钥与本网站的登录已集成在一起,也就是说,你在该网站扫码登录之后会自动刷新 API 密钥。</span
>
</p>
</li>
<li>
<p>
<span>由于该密钥与网站用的同一套体系,网站的登录信息失效时,对应的 API 密钥也将失效。</span>
</p>
</li>
</ol>
<UButton class="mt-3" color="blue" :loading="loading" @click="getAuthKey">
查询 API 密钥 (确保当前登录信息有效)
</UButton>
<div v-if="authKey">
<p class="mt-5 mb-2">当前密钥:</p>
<CodeSegment :code="authKey" lang="text" class="max-w-xl" />
</div>
</template>
</UAlert>
</div>
</template>
================================================
FILE: components/base/DatePicker.vue
================================================
<script setup lang="ts">
import { DatePicker as VCalendarDatePicker } from 'v-calendar';
import 'v-calendar/dist/style.css';
import dayjs from 'dayjs';
import { MP_ORIGIN_TIMESTAMP } from '~/config';
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: {
type: Number,
default: null,
},
});
const emit = defineEmits(['update:model-value', 'close']);
const date = computed({
get: () => dayjs.unix(props.modelValue).toDate(),
set: value => {
emit('update:model-value', dayjs(value).unix());
emit('close');
},
});
const attrs = {
transparent: true,
borderless: true,
locale: 'zh-CN',
color: 'gray',
'is-dark': { selector: 'html', darkClass: 'dark' },
'first-day-of-week': 2,
'min-date': dayjs.unix(MP_ORIGIN_TIMESTAMP).toDate(),
'max-date': new Date(),
};
function onDayClick(_: any, event: MouseEvent): void {
const target = event.target as HTMLElement;
target.blur();
}
</script>
<template>
<VCalendarDatePicker v-model="date" v-bind="{ ...attrs, ...$attrs }" @dayclick="onDayClick" />
</template>
================================================
FILE: components/base/ExternalLink.vue
================================================
<script setup lang="ts">
interface Props {
href: string;
text: string;
}
defineProps<Props>();
</script>
<template>
<a :href="href" target="_blank" class="underline text-blue-600 underline-offset-2">{{ text }}</a>
</template>
================================================
FILE: components/base/Tag.vue
================================================
<script setup lang="ts">
interface Props {
label?: string;
color: 'green' | 'red' | 'rose' | 'blue';
}
const props = defineProps<Props>();
// Map color prop to Tailwind CSS classes
const colorClasses = {
green: {
bg: 'bg-green-50',
text: 'text-green-700',
ring: 'ring-green-600/20',
},
red: {
bg: 'bg-red-50',
text: 'text-red-700',
ring: 'ring-red-600/20',
},
rose: {
bg: 'bg-rose-50',
text: 'text-rose-700',
ring: 'ring-rose-600/20',
},
blue: {
bg: 'bg-blue-50',
text: 'text-blue-700',
ring: 'ring-blue-600/20',
},
};
// Get the classes based on the color prop
const classes = colorClasses[props.color];
</script>
<template>
<span
:class="[classes.bg, classes.text, classes.ring]"
class="inline-flex items-center rounded px-2 py-1 text-xs font-medium ring-1"
>
<slot name="default">{{ label }}</slot>
</span>
</template>
================================================
FILE: components/dashboard/Actions.vue
================================================
<script setup lang="ts">
import type { ChipColor } from '#ui/types';
import CredentialsDialog, { type CredentialState } from '~/components/global/CredentialsDialog.vue';
import QQGroupModal from '~/components/modal/QQGroup.vue';
import { docsWebSite } from '~/config';
import { gotoLink } from '~/utils';
const modal = useModal();
// CredentialDialog 相关变量
const credentialsDialogOpen = ref(false);
const credentialState = ref<CredentialState>('inactive');
const credentialPendingCount = ref(0);
const credentialColor: ComputedRef<ChipColor> = computed<ChipColor>(() => {
switch (credentialState.value) {
case 'active':
return 'green';
case 'inactive':
return 'gray';
case 'warning':
return 'amber';
default:
return 'gray';
}
});
const credentialBadgeText = computed(() => {
const count = credentialPendingCount.value;
if (count <= 0) return '';
return count > 9 ? '+' : `${count}`;
});
const isCredentialActive = computed(() => credentialState.value === 'active');
</script>
<template>
<ul class="hidden md:flex items-center gap-5">
<!-- 通知 -->
<!-- <li>-->
<!-- <UTooltip text="通知">-->
<!-- <UChip text="3" size="2xl" color="amber">-->
<!-- <UIcon name="i-lucide:bell" class="action-icon" />-->
<!-- </UChip>-->
<!-- </UTooltip>-->
<!-- </li>-->
<li>
<UTooltip text="加入QQ群">
<UIcon
@click="modal.open(QQGroupModal)"
name="i-tdesign:logo-qq-filled"
class="size-7 text-zinc-400 hover:text-blue-500 cursor-pointer transition-colors"
/>
</UTooltip>
</li>
<!-- Credential -->
<li>
<CredentialsDialog
v-model:open="credentialsDialogOpen"
v-model:state="credentialState"
@update:pending-count="credentialPendingCount = $event"
/>
<UTooltip text="抓取 Credentials">
<div class="relative">
<UIcon
@click="credentialsDialogOpen = true"
name="i-lucide:dog"
:class="[
'size-7 cursor-pointer transition-colors',
{ 'text-zinc-400 hover:text-blue-500': !isCredentialActive },
{ 'text-green-500 hover:text-green-600': isCredentialActive },
]"
/>
<span
v-if="credentialBadgeText"
class="absolute -top-1 -right-1 text-[10px] leading-none rounded-full bg-rose-500 text-white px-1.5 py-0.5 min-w-[16px] text-center"
>
{{ credentialBadgeText }}
</span>
</div>
</UTooltip>
</li>
<!-- 文档 -->
<li>
<UTooltip text="文档">
<UIcon
name="i-lucide:book-open"
@click="gotoLink(docsWebSite)"
class="size-7 text-zinc-400 hover:text-blue-500 cursor-pointer transition-colors"
/>
</UTooltip>
</li>
<!-- GitHub -->
<li>
<UTooltip text="GitHub">
<UIcon
@click="gotoLink('https://github.com/wechat-article/wechat-article-exporter')"
name="i-lucide:github"
class="size-7 text-zinc-400 hover:text-blue-500 cursor-pointer transition-colors"
/>
</UTooltip>
</li>
</ul>
</template>
================================================
FILE: components/dashboard/AuthPopoverPanel.vue
================================================
<script setup lang="ts">
const { loggedIn, user, clear, openInPopup, session } = useUserSession();
const open = defineModel<boolean>('open', { default: false });
// 登入功能
const loginWithGitHub = async () => {
deleteCookie('nuxt-auth-state');
window.location.href = '/auth/github';
};
const loginWithGoogle = async () => {
deleteCookie('nuxt-auth-state');
window.location.href = '/auth/google';
};
// 登出功能
const logout = async () => {};
// 由于 nuxt-auth-utils 库在授权时的bug,在授权之前需要手动删除临时cookie
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
interface Action {
id: string;
name: string;
onClick: () => Promise<void> | void;
}
function closePanel(): void {
open.value = false;
}
const actions: Action[] = [
{
id: 'profile',
name: '个人中心',
onClick: async () => {
closePanel();
navigateTo('/dashboard/profile');
},
},
{
id: 'logout',
name: '退出',
onClick: async () => {
closePanel();
await clear();
},
},
];
</script>
<template>
<div>
<div v-if="loggedIn && user" class="min-w-60">
<div class="py-5 flex flex-col items-center">
<img :alt="user.username" :src="user.avatar" class="size-20 rounded-full" />
<h2 class="flex items-center gap-1.5 pt-1 font-bold">
<span>{{ user.name }}</span>
<UBadge
:ui="{ rounded: 'rounded-full' }"
:color="user.plan === 'pro' ? 'fuchsia' : 'gray'"
variant="subtle"
size="xs"
class="px-2 font-mono font-bold"
>{{ user.plan }}</UBadge
>
</h2>
<div class="flex items-center gap-1.5">
<UIcon v-if="user.provider === 'GitHub'" name="i-logos:github-icon" class="text-gray-200" />
<UIcon v-if="user.provider === 'Google'" name="i-logos:google-icon" class="text-gray-200" />
<span>{{ user.username }}</span>
</div>
<div class="mt-3" v-if="user.plan === 'free'">
<!-- <UButton>订阅 Pro 会员</UButton>-->
<UButton
:ui="{ rounded: 'rounded-full', padding: { sm: 'px-5' } }"
class="flex gap-2 items-center rounded-full border-2 border-primary text-primary font-medium bg-white hover:bg-primary hover:text-white transition-colors px-5 py-1.5 text-sm"
>订阅 Pro 会员 (10元/月)</UButton
>
</div>
</div>
<div class="w-full">
<ul>
<li
v-for="action in actions"
:key="action.id"
class="px-4 border-t border-gray-200/50 flex justify-between items-center hover:bg-gray-100/60"
>
<a @click="action.onClick" class="flex-1 py-2.5 cursor-pointer">{{ action.name }}</a>
</li>
</ul>
</div>
</div>
<!-- Providers -->
<div v-else class="py-5 flex flex-col p-3 min-w-60">
<p class="font-serif">Log into with</p>
<div class="space-y-3 my-3">
<!-- GitHub -->
<UButton @click="loginWithGitHub" block color="white" size="xl" icon="i-logos:github-icon">GitHub</UButton>
<!-- Google -->
<UButton @click="loginWithGoogle" block color="white" size="xl" icon="i-logos:google-icon">Google</UButton>
</div>
</div>
</div>
</template>
================================================
FILE: components/dashboard/BottomPanel.vue
================================================
<script setup lang="ts">
import { formatDistance } from 'date-fns';
import { request } from '#shared/utils/request';
import LoginModal from '~/components/modal/Login.vue';
import StorageUsage from '~/components/StorageUsage.vue';
import { IMAGE_PROXY } from '~/config';
import type { LogoutResponse } from '~/types/types';
const loginAccount = useLoginAccount();
const modal = useModal();
const now = ref(new Date());
const distance = computed(() => {
return (
loginAccount.value &&
formatDistance(new Date(loginAccount.value.expires), now.value, {
includeSeconds: true,
locale: {
formatDistance: function (token, count, options) {
if (now.value >= new Date(loginAccount.value.expires)) {
window.clearInterval(timer);
setTimeout(() => {
loginAccount.value = null;
}, 0);
return '已过期';
}
switch (token) {
case 'aboutXHours':
return '大约' + count + '个小时';
case 'aboutXMonths':
return '大约' + count + '个月';
case 'aboutXWeeks':
return '大约' + count + '周';
case 'aboutXYears':
return '大约' + count + '年';
case 'lessThanXMinutes':
return '小于' + count + '分钟';
case 'almostXYears':
return '接近' + count + '年';
case 'halfAMinute':
return '半分钟';
case 'lessThanXSeconds':
return '小于' + count + '秒';
case 'overXYears':
return '超过' + count + '年';
case 'xDays':
return count + '天';
case 'xHours':
return count + '个小时';
case 'xMinutes':
return count + '分钟';
case 'xMonths':
return count + '个月';
case 'xSeconds':
return count + '秒';
case 'xWeeks':
return count + '周';
case 'xYears':
return count + '年';
default:
return 'unknown';
}
},
},
})
);
});
const warning = computed(() => {
const value = distance.value;
return value === '已过期' || value.includes('分钟') || value.includes('秒');
});
function login() {
modal.open(LoginModal);
}
const logoutBtnLoading = ref(false);
async function logout() {
logoutBtnLoading.value = true;
const { statusCode, statusText } = await request<LogoutResponse>('/api/web/mp/logout');
if (statusCode === 200) {
loginAccount.value = null;
} else {
alert(statusText);
}
logoutBtnLoading.value = false;
}
let timer: number;
onMounted(() => {
timer = window.setInterval(() => {
now.value = new Date();
}, 1000);
});
onUnmounted(() => {
window.clearInterval(timer);
});
</script>
<template>
<footer class="flex flex-col space-y-2 pt-3 border-t dark:border-slate-600">
<div v-if="loginAccount" class="space-y-3">
<div class="flex items-center space-x-2">
<img
v-if="loginAccount.avatar"
:src="IMAGE_PROXY + loginAccount.avatar"
alt=""
class="rounded-full size-10 ring-1 ring-gray-300"
/>
<UTooltip
v-if="loginAccount.nickname"
class="flex-1 overflow-hidden"
:popper="{ placement: 'top-start', offsetDistance: 16 }"
>
<template #text>
<span>{{ loginAccount.nickname }}</span>
</template>
<span class="whitespace-nowrap text-ellipsis overflow-hidden">{{ loginAccount.nickname }}</span>
</UTooltip>
<UButton
icon="i-heroicons-arrow-left-start-on-rectangle-16-solid"
:loading="logoutBtnLoading"
class="bg-slate-10 hover:bg-rose-500 disabled:bg-rose-500"
@click="logout"
>退出
</UButton>
</div>
<div class="text-sm">
<span>登录信息过期时间还剩: </span>
<span class="font-mono" :class="warning ? 'text-rose-500' : 'text-green-500'">{{ distance }}</span>
</div>
</div>
<div v-else>
<UButton color="gray" variant="solid" @click="login">登录公众号</UButton>
</div>
<StorageUsage />
</footer>
</template>
================================================
FILE: components/dashboard/NavMenus.vue
================================================
<script setup lang="ts">
interface NavItem {
name: string;
icon: string;
href: string;
insider?: boolean;
tags?: string[];
}
const items = ref<NavItem[]>([
{ name: '公众号管理', icon: 'i-lucide:users', href: '/dashboard/account' },
{ name: '文章下载', icon: 'i-lucide:file-down', href: '/dashboard/article' },
{ name: '单篇文章下载', icon: 'i-lucide:file-text', href: '/dashboard/single' },
{ name: '合集下载', icon: 'i-lucide:library-big', href: '/dashboard/album' },
{ name: '公共代理', icon: 'i-lucide:globe', href: '/dashboard/proxy' },
{ name: 'API', icon: 'i-lucide:cable', href: '/dashboard/api' },
{ name: '设置', icon: 'i-lucide:settings', href: '/dashboard/settings' },
{ name: '技术支持 & 赞助', icon: 'i-lucide:heart-handshake', href: '/dashboard/support' },
]);
</script>
<template>
<nav class="flex-1 mt-6">
<ul class="flex flex-col gap-2">
<li v-for="item in items" :key="item.name">
<NuxtLink :to="item.href" class="flex h-8 items-center gap-2 rounded-md px-2 text-sm nav-link">
<UIcon :name="item.icon" class="size-5 opacity-80" />
<p>{{ item.name }}</p>
<UBadge v-if="item.tags" v-for="tag in item.tags" color="fuchsia" variant="subtle">{{ tag }}</UBadge>
</NuxtLink>
</li>
</ul>
</nav>
</template>
<style scoped>
.nav-link.router-link-active {
@apply text-slate-12 dark:text-slate-200 bg-slate-3 dark:bg-slate-800 font-bold;
}
.nav-link:not(.router-link-active) {
@apply text-slate-11 dark:text-slate-200 hover:bg-slate-4 dark:hover:bg-slate-800 hover:text-slate-12;
}
</style>
================================================
FILE: components/dashboard/SideBar.vue
================================================
<script setup lang="ts">
import BottomPanel from '~/components/dashboard/BottomPanel.vue';
import NavMenus from '~/components/dashboard/NavMenus.vue';
import { websiteName } from '~/config';
</script>
<template>
<aside
class="hidden md:flex flex-col h-screen w-[250px] flex-shrink-0 justify-between border-r border-slate-4 dark:border-slate-700 bg-slate-1 px-4 pb-6"
>
<!-- 网站标题 & Logo -->
<div class="flex items-center h-[60px]">
<NuxtLink to="/" class="px-2 font-bold text-xl">{{ websiteName }}</NuxtLink>
</div>
<!-- 导航菜单 -->
<NavMenus />
<!-- 底部视图 -->
<BottomPanel />
</aside>
</template>
================================================
FILE: components/global/CredentialsDialog.vue
================================================
<template>
<USlideover v-model="open" :ui="{ width: 'max-w-[500px]' }">
<UCard
class="flex flex-col flex-1"
:ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }"
>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-bold text-2xl">抓取 Credentials</h2>
</div>
</template>
<div>
<UTabs
:items="tabs"
:ui="{ list: { marker: { background: 'bg-blue-500 text-white' }, tab: { active: 'text-white' } } }"
>
<template #item="{ item }">
<div v-if="item.key === 'wxdown'" class="space-y-5">
<p class="flex items-center text-sm">
<span class="text-rose-500 font-semibold">所需软件:</span>
<UButton @click="downloadProgram" variant="ghost" color="gray"
>去下载 wxdown-service 程序
<UIcon name="i-lucide:arrow-up-right" class="size-5" />
</UButton>
</p>
<div class="flex justify-between items-center gap-3">
<UInput
class="flex-1"
color="gray"
type="url"
v-model="wsURL"
:disabled="monitoring || wsMonitoring"
placeholder="请输入 ws 监听地址"
/>
<UButton
v-if="!wsMonitoring"
:disabled="!wsURL || monitoring"
color="blue"
@click="startListenService(true)"
>
开始监控
</UButton>
<UButton v-else icon="i-line-md:loading-twotone-loop" color="green" @click="stopListenService"
>监控中,结束监控</UButton
>
</div>
</div>
<div v-if="item.key === 'mitmproxy'">
<p class="flex items-center text-sm">
<span class="text-rose-500 font-semibold">所需软件:</span>
<UButton @click="downloadPlugin" variant="ghost" color="gray"
>去下载 mitmproxy 插件
<UIcon name="i-lucide:arrow-up-right" class="size-5" />
</UButton>
</p>
<div class="text-sm my-5">
<p class="flex justify-between items-end">执行以下命令启动 mitmproxy 服务并加载 credential.py 插件:</p>
<p class="flex justify-between items-center bg-black text-white p-2 my-2 rounded-md">
<code>mitmdump -s credential.py -q</code>
<UIcon v-if="copied" name="i-lucide:copy-check" />
<UIcon
v-else
name="i-lucide:copy"
class="cursor-pointer"
@click="copy('mitmdump -s credential.py -q')"
/>
</p>
</div>
<div class="flex justify-between items-center gap-3">
<UInput
class="flex-1"
color="gray"
v-model="apiKey"
:disabled="authorized || wsMonitoring"
placeholder="请输入API Key"
/>
<UButton
class="px-5"
color="blue"
:loading="authorizeBtnLoading"
:disabled="!apiKey || authorized || wsMonitoring || monitoring"
@click="authorize"
>认证</UButton
>
<UButton v-if="!monitoring" :disabled="!authorized || wsMonitoring" color="blue" @click="start"
>开始监控</UButton
>
<UButton v-else icon="i-line-md:loading-twotone-loop" color="green" @click="stop"
>监控中,结束监控</UButton
>
</div>
</div>
</template>
</UTabs>
<ul class="flex flex-col mt-3 p-1 gap-4 overflow-y-scroll h-[calc(100vh-20rem)] no-scrollbar">
<li
v-for="credential in credentials"
:key="credential.biz"
class="relative flex items-center border rounded-md hover:ring ring-blue-500 hover:shadow-md transition-all duration-300 p-3 space-x-5"
>
<div class="size-20 border rounded-full">
<img :src="credential.avatar" alt="" />
</div>
<div class="flex-1">
<p>公众号名称:{{ credential.nickname || '--' }}</p>
<p>fakeid: {{ credential.biz }}</p>
<p>获取时间: {{ credential.time }}</p>
<div class="flex items-center justify-between mt-4">
<span v-if="credential.valid" class="font-sans font-bold text-green-500">有效</span>
<span v-else class="font-sans font-bold text-rose-500">已过期</span>
<UButton
size="xs"
:color="credential.added ? 'green' : 'blue'"
:variant="credential.added ? 'soft' : 'solid'"
:disabled="credential.added || addingBiz === credential.biz"
:loading="addingBiz === credential.biz"
@click="addAccount(credential)"
>
{{ credential.added ? '已添加' : '添加公众号' }}
</UButton>
</div>
</div>
<UButton
v-if="isDev"
:loading="pullArticleLoading"
class="absolute top-3 right-3"
@click="pullData(credential.biz)"
>
拉取数据
</UButton>
</li>
</ul>
</div>
</UCard>
</USlideover>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { getArticleList, getArticleListWithCredential } from '~/apis';
import LoginModal from '~/components/modal/Login.vue';
import toastFactory from '~/composables/toast';
import useLoginCheck from '~/composables/useLoginCheck';
import { CREDENTIAL_API_HOST, CREDENTIAL_LIVE_MINUTES, isDev } from '~/config';
import { getInfoCache, type MpAccount } from '~/store/v2/info';
import type { ParsedCredential } from '~/types/credential';
export type CredentialState = 'active' | 'inactive' | 'warning';
const emit = defineEmits<{
(e: 'update:pendingCount', value: number): void;
}>();
const open = defineModel<boolean>('open', { default: false });
const state = defineModel<CredentialState>('state', { default: 'inactive' });
const pullArticleLoading = ref(false);
async function pullData(fakeid: string) {
pullArticleLoading.value = true;
const articles = await getArticleListWithCredential(fakeid);
console.log(articles);
pullArticleLoading.value = false;
}
const tabs = [
{
key: 'wxdown',
label: 'wxdown 程序版',
},
{
key: 'mitmproxy',
label: 'mitmproxy 插件版',
},
];
const { checkLogin } = useLoginCheck();
const credentials = useLocalStorage<ParsedCredential[]>('auto-detect-credentials:credentials', []);
for (const item of credentials.value) {
item.valid = Date.now() < item.timestamp + 1000 * 60 * CREDENTIAL_LIVE_MINUTES;
}
const validCredentialCount = computed(() => credentials.value.filter(c => c.valid).length);
const pendingCredentialCount = computed(() => credentials.value.filter(c => c.valid && !c.added).length);
const toast = toastFactory();
const modal = useModal();
const addingBiz = ref<string | null>(null);
async function refreshCredentialAddedState() {
const pending = credentials.value.map(async credential => {
const info = await getInfoCache(credential.biz);
credential.added = Boolean(info);
});
await Promise.allSettled(pending);
}
// 监听账号事件,及时更新当前凭据项的按钮状态
const { accountEventBus } = useAccountEventBus();
accountEventBus.on((event, payload) => {
if (event === 'account-added') {
const target = credentials.value.find(item => item.biz === payload?.fakeid);
if (target) {
target.added = true;
}
} else if (event === 'account-removed') {
const target = credentials.value.find(item => item.biz === payload?.fakeid);
if (target) {
target.added = false;
}
}
});
interface Credential {
url: string;
set_cookie: string;
timestamp: number;
name: string;
avatar: string;
}
let timer: number;
let manulStopped = false;
let listenRetryTimer: number | null = null;
const monitoring = ref(JSON.parse(localStorage.getItem('auto-detect-credentials:monitoring') as string) || false);
function start() {
monitoring.value = true;
const oldTimer = localStorage.getItem('auto-detect-credentials:monitoring-timer');
if (oldTimer) {
window.clearInterval(parseInt(oldTimer));
}
fetchCredentials();
timer = window.setInterval(() => {
fetchCredentials();
}, 3000);
localStorage.setItem('auto-detect-credentials:monitoring', 'true');
localStorage.setItem('auto-detect-credentials:monitoring-timer', timer.toString());
}
function stop() {
monitoring.value = false;
localStorage.setItem('auto-detect-credentials:monitoring', 'false');
window.clearInterval(timer);
}
// 监听服务重试机制
function scheduleListenRetry() {
if (listenRetryTimer) {
window.clearTimeout(listenRetryTimer);
}
// 如果是手动停止的,则不重试
if (manulStopped) return;
listenRetryTimer = window.setTimeout(() => {
startListenService();
}, 5000);
}
// 清除重试定时器
function clearRetryTimer() {
if (listenRetryTimer) {
window.clearTimeout(listenRetryTimer);
listenRetryTimer = null;
}
}
onMounted(() => {
if (monitoring.value) {
start();
}
refreshCredentialAddedState();
startListenService();
});
onUnmounted(() => {
clearRetryTimer();
});
// 下载 credential.py 插件
async function downloadPlugin() {
const link = document.createElement('a');
link.href = '/plugins/credential.py';
link.download = 'credential.py';
link.click();
}
// 下载 wxdown-service 程序
async function downloadProgram() {
const link = document.createElement('a');
link.target = '_blank';
link.href = 'https://github.com/wechat-article/wxdown-service/releases';
link.download = 'wxdown-service';
link.click();
}
const apiKey = ref(localStorage.getItem('auto-detect-credentials:apikey') as string);
const authorizeBtnLoading = ref(false);
const authorized = ref(false);
// 认证
async function authorize() {
try {
authorizeBtnLoading.value = true;
const response = await fetch(`${CREDENTIAL_API_HOST}/authorize`, {
method: 'GET',
headers: {
Authorization: apiKey.value,
},
});
if (response.status === 200) {
authorized.value = true;
localStorage.setItem('auto-detect-credentials:apikey', apiKey.value);
alert('认证成功');
} else {
authorized.value = false;
localStorage.removeItem('auto-detect-credentials:apikey');
alert('认证失败,请确认 API Key 是否正确');
}
} catch (error: any) {
if (error.message === 'Failed to fetch') {
alert('mitmproxy 服务未启动');
} else {
alert(error.message);
}
authorized.value = false;
} finally {
authorizeBtnLoading.value = false;
}
}
// 获取数据
async function fetchCredentials() {
let result: Credential[] = [];
try {
const response = await fetch(`${CREDENTIAL_API_HOST}/credentials`, {
method: 'GET',
headers: {
Authorization: apiKey.value,
},
});
if (response.status === 404) {
result = [];
} else if (response.status !== 200) {
authorized.value = false;
stop();
return;
} else {
result = await response.json();
}
} catch (error) {
console.error(error);
authorized.value = false;
stop();
return;
}
const _credentials: ParsedCredential[] = [];
for (const item of result) {
const searchParams = new URL(item.url).searchParams;
const __biz = searchParams.get('__biz')!;
const uin = searchParams.get('uin')!;
const key = searchParams.get('key')!;
const pass_ticket = searchParams.get('pass_ticket')!;
let wap_sid2 = null;
const matchResult = item.set_cookie.match(/wap_sid2=(?<wap_sid2>.+?);/);
if (matchResult && matchResult.groups && matchResult.groups.wap_sid2) {
wap_sid2 = matchResult.groups.wap_sid2;
}
// 验证完整性
if (!__biz || !uin || !key || !pass_ticket || !wap_sid2) {
continue;
}
const info = await getInfoCache(__biz);
_credentials.push({
nickname: item.name || info?.nickname,
avatar: item.avatar || info?.round_head_img,
biz: __biz,
uin: uin,
key: key,
pass_ticket: pass_ticket,
wap_sid2: wap_sid2,
timestamp: item.timestamp,
time: dayjs(item.timestamp).format('YYYY-MM-DD HH:mm:ss'),
valid: Date.now() < item.timestamp + 1000 * 60 * CREDENTIAL_LIVE_MINUTES,
added: Boolean(info),
});
}
credentials.value = _credentials.sort((a, b) => b.timestamp - a.timestamp);
}
const wsURL = ref('ws://127.0.0.1:65001');
const wsMonitoring = ref(false);
let _ws: WebSocket | null = null;
// 启动监听服务
async function startListenService(isManual = false) {
const url = wsURL.value.trim();
if (!url) {
return;
}
if (isManual) {
// 手动启动时,取消手动停止标记
manulStopped = false;
}
const ws = new WebSocket(url);
ws.addEventListener('open', () => {
wsMonitoring.value = true;
_ws = ws;
clearRetryTimer();
});
ws.addEventListener('message', async evt => {
let result = [];
try {
result = JSON.parse(evt.data);
} catch (e) {
console.warn('解析失败: ', e);
}
const _credentials: ParsedCredential[] = [];
for (const item of result) {
const searchParams = new URL(item.url).searchParams;
const __biz = searchParams.get('__biz')!;
const uin = searchParams.get('uin')!;
const key = searchParams.get('key')!;
const pass_ticket = searchParams.get('pass_ticket')!;
let wap_sid2 = null;
const matchResult = item.set_cookie.match(/wap_sid2=(?<wap_sid2>.+?);/);
if (matchResult && matchResult.groups && matchResult.groups.wap_sid2) {
wap_sid2 = matchResult.groups.wap_sid2;
}
// 验证完整性
if (!__biz || !uin || !key || !pass_ticket || !wap_sid2) {
continue;
}
const info = await getInfoCache(__biz);
_credentials.push({
nickname: item.name || info?.nickname,
avatar: item.avatar || info?.round_head_img,
biz: __biz,
uin: uin,
key: key,
pass_ticket: pass_ticket,
wap_sid2: wap_sid2,
timestamp: item.timestamp,
time: dayjs(item.timestamp).format('YYYY-MM-DD HH:mm:ss'),
valid: Date.now() < item.timestamp + 1000 * 60 * CREDENTIAL_LIVE_MINUTES,
added: Boolean(info),
});
}
credentials.value = _credentials.sort((a, b) => b.timestamp - a.timestamp);
});
ws.addEventListener('close', () => {
wsMonitoring.value = false;
_ws = null;
scheduleListenRetry();
});
ws.addEventListener('error', evt => {
scheduleListenRetry();
});
}
// 停止监听服务
async function stopListenService() {
manulStopped = true;
if (_ws) {
_ws.close();
}
clearRetryTimer();
}
async function addAccount(credential: ParsedCredential) {
if (credential.added || addingBiz.value === credential.biz) {
return;
}
if (!checkLogin()) return;
addingBiz.value = credential.biz;
const nickname = credential.nickname || credential.biz;
const account: MpAccount = {
fakeid: credential.biz,
completed: false,
count: 0,
articles: 0,
total_count: 0,
nickname: credential.nickname,
round_head_img: credential.avatar,
};
try {
await getArticleList(account, 0);
credential.added = true;
toast.success('公众号添加成功', `已成功添加公众号【${nickname}】`);
// 通知其他视图(如公众号管理列表)立即刷新
accountEventBus.emit('account-added', { fakeid: credential.biz });
} catch (error: any) {
if (error?.message === 'session expired') {
modal.open(LoginModal);
} else {
toast.error('添加公众号失败', error?.message || '未知错误');
}
} finally {
addingBiz.value = null;
}
}
watchEffect(() => {
if (!monitoring.value && !wsMonitoring.value) {
state.value = 'inactive';
} else if (monitoring.value || wsMonitoring.value) {
state.value = 'active';
} else {
state.value = 'warning';
}
});
watchEffect(() => {
emit('update:pendingCount', pendingCredentialCount.value);
});
const copied = ref(false);
function copy(text: string) {
navigator.clipboard.writeText(text);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 1000);
}
</script>
================================================
FILE: components/global/SearchAccountDialog.vue
================================================
<template>
<USlideover v-model="isOpen" side="left" :ui="{ overlay: { background: 'bg-zinc-400/75' } }">
<div
class="rounded-lg divide-y divide-gray-100 dark:divide-gray-800 shadow bg-white dark:bg-gray-900 flex flex-col flex-1 overflow-y-scroll"
>
<div class="sticky top-0 bg-white py-4 px-2 shadow">
<SearchAccountForm v-model="accountQuery" @search="searchAccount" />
</div>
<div class="flex-1">
<ul class="divide-y antialiased">
<li
v-for="account in accountList"
:key="account.fakeid"
class="flex items-center px-2 py-4 hover:bg-slate-50 hover:cursor-pointer"
@click="selectAccount(account)"
>
<img class="size-20 mr-2" :src="account.round_head_img" alt="" />
<div class="flex-1">
<div class="flex justify-between">
<p class="font-semibold">{{ account.nickname }}</p>
<p class="text-sky-500 font-medium">
{{ ACCOUNT_TYPE[account.service_type] }}
</p>
</div>
<p class="text-gray-500 text-sm">微信号: {{ account.alias || '未设置' }}</p>
<p class="text-sm mt-2">{{ account.signature }}</p>
</div>
</li>
</ul>
<p v-if="loading" class="flex justify-center items-center my-2 py-2">
<Loader :size="28" class="animate-spin text-slate-500" />
</p>
<p v-else-if="noMoreData" class="text-center mt-2 py-2 text-slate-400">已全部加载完毕</p>
<button
v-else-if="accountList.length > 0"
@click="loadData"
class="block mx-auto my-2 h-10 px-6 font-semibold rounded-md border border-slate-200 text-slate-900 dark:text-slate-300 hover:border-slate-400"
type="button"
>
加载更多
</button>
</div>
</div>
</USlideover>
</template>
<script setup lang="ts">
import { Loader } from 'lucide-vue-next';
import { getAccountList } from '~/apis';
import LoginModal from '~/components/modal/Login.vue';
import { ACCOUNT_LIST_PAGE_SIZE, ACCOUNT_TYPE } from '~/config';
import type { AccountInfo } from '~/types/types';
const toast = useToast();
const modal = useModal();
const isOpen = ref(false);
function openSwitcher() {
isOpen.value = true;
}
const accountQuery = ref('');
const accountList = reactive<AccountInfo[]>([]);
let begin = 0;
/**
* 搜索公众号
*/
async function searchAccount() {
begin = 0;
accountList.length = 0;
noMoreData.value = false;
await loadData();
}
const loading = ref(false);
const noMoreData = ref(false);
/**
* 加载公众号数据
*/
async function loadData() {
loading.value = true;
try {
const [accounts, completed] = await getAccountList(begin, accountQuery.value);
accountList.push(...accounts);
begin += ACCOUNT_LIST_PAGE_SIZE;
noMoreData.value = completed;
} catch (e: any) {
if (e.message === 'session expired') {
modal.open(LoginModal);
} else {
console.error(e);
toast.add({
color: 'rose',
title: '错误',
description: e.message,
icon: 'i-octicon:bell-24',
});
}
} finally {
loading.value = false;
}
}
/**
* 选择公众号
* @param account
*/
function selectAccount(account: AccountInfo) {
isOpen.value = false;
emit('select:account', account);
}
const emit = defineEmits(['select:account']);
defineExpose({
open: openSwitcher,
});
</script>
================================================
FILE: components/grid/AccountActions.vue
================================================
<script setup lang="ts">
import type { ICellRendererParams } from 'ag-grid-community';
import { Loader } from 'lucide-vue-next';
interface Props {
params: ICellRendererParams & {
onSync?: (params: ICellRendererParams) => void;
onStop?: (params: ICellRendererParams) => void;
isDeleting: boolean;
isSyncing: boolean;
syncingRowId: string | null;
};
}
const props = defineProps<Props>();
function sync() {
props.params.onSync && props.params.onSync(props.params);
}
function stop() {
props.params.onStop && props.params.onStop(props.params);
}
const isDisabled = computed(() => props.params.isDeleting || props.params.isSyncing);
const isLoading = computed(() => props.params.isSyncing && props.params.node.id === props.params.syncingRowId);
</script>
<template>
<div class="flex items-center justify-center gap-3">
<UButton v-if="isLoading" color="green" size="xs" variant="solid" @click="stop">
<Loader :size="14" class="animate-spin" />
停止</UButton
>
<UButton
v-else
icon="i-heroicons:arrow-path-rounded-square-20-solid"
color="blue"
size="xs"
:disabled="isDisabled"
@click="sync"
></UButton>
</div>
</template>
================================================
FILE: components/grid/Album.vue
================================================
<script setup lang="ts">
import type { ICellRendererParams } from 'ag-grid-community';
interface Props {
params: ICellRendererParams;
}
defineProps<Props>();
</script>
<template>
<p class="flex flex-wrap">
<span v-for="album in params.data.appmsg_album_infos" :key="album.id" class="text-blue-600 mr-2"
>#{{ album.title }}</span
>
</p>
</template>
================================================
FILE: components/grid/ArticleActions.vue
================================================
<script setup lang="ts">
import type { ICellRendererParams } from 'ag-grid-community';
interface Props {
params: ICellRendererParams & {
onGotoLink?: (params: ICellRendererParams) => void;
onPreview?: (params: ICellRendererParams) => void;
};
}
const props = defineProps<Props>();
function gotoLink() {
props.params.onGotoLink && props.params.onGotoLink(props.params);
}
function preview() {
props.params.onPreview && props.params.onPreview(props.params);
}
</script>
<template>
<div class="flex items-center justify-center">
<UTooltip text="访问原文" :popper="{ placement: 'top' }">
<UButton icon="i-lucide:external-link" color="blue" square variant="ghost" @click="gotoLink" />
</UTooltip>
<UTooltip text="预览" :popper="{ placement: 'top' }">
<UButton
:disabled="!params.data.contentDownload || params.data.downloading"
icon="i-heroicons:fire-16-solid"
:color="params.data.contentDownload ? 'blue' : 'rose'"
square
variant="ghost"
@click="preview"
/>
</UTooltip>
</div>
</template>
================================================
FILE: components/grid/BooleanCellRenderer.vue
================================================
<script setup lang="ts">
import type { ICellRendererParams } from 'ag-grid-community';
import { Square, SquareCheckBig } from 'lucide-vue-next';
interface Props {
params: ICellRendererParams;
}
defineProps<Props>();
</script>
<template>
<p v-if="params.node.group">
<span>{{ params.value }}</span>
</p>
<p class="flex flex-wrap" v-else>
<SquareCheckBig v-if="params.value" class="size-5 text-gray-400" />
<Square v-else class="size-5 text-gray-400" />
</p>
</template>
================================================
FILE: components/grid/CoverTooltip.vue
================================================
<script setup lang="ts">
import type { ITooltipParams } from 'ag-grid-community';
interface Props {
params: ITooltipParams;
}
defineProps<Props>();
</script>
<template>
<img alt="" :src="params.value" style="max-width: 300px; max-height: 300px; object-fit: contain" />
</template>
================================================
FILE: components/grid/LoadProgress.vue
================================================
<script setup lang="ts">
import type { ICellRendererParams } from 'ag-grid-community';
interface Props {
params: ICellRendererParams;
}
const props = defineProps<Props>();
const count = ref(props.params.data.count);
const total = ref(props.params.data.total_count || Number.MAX_SAFE_INTEGER);
function refresh(params: ICellRendererParams): boolean {
count.value = params.data.count;
total.value = params.data.total_count || Number.MAX_SAFE_INTEGER;
return true;
}
</script>
<template>
<div class="mt-0">
<UProgress color="sky" :value="count" :max="total" indicator />
</div>
</template>
================================================
FILE: components/grid/Loading.vue
================================================
<script setup lang="ts">
import { Loader } from 'lucide-vue-next';
</script>
<template>
<div>
<Loader :size="28" class="animate-spin text-slate-500" />
</div>
</template>
================================================
FILE: components/grid/NoRows.vue
================================================
<script setup lang="ts"></script>
<template>
<p class="flex flex-col items-center text-lg gap-2">
<UIcon name="i-humbleicons:coffee" class="size-10" />
<span class="font-bold font-serif">暂无数据</span>
</p>
</template>
================================================
FILE: components/grid/StatusBar.vue
================================================
<script setup lang="ts">
import type { IStatusPanelParams } from 'ag-grid-community';
interface Props {
params: IStatusPanelParams;
}
const props = defineProps<Props>();
const selectedRowCount = ref(0);
const displayedRowCount = ref(0);
function refresh() {
selectedRowCount.value = props.params.api.getSelectedRows().length;
displayedRowCount.value = props.params.api.getDisplayedRowCount();
}
onMounted(() => {
props.params.api.addEventListener('rowDataUpdated', refresh);
props.params.api.addEventListener('selectionChanged', refresh);
props.params.api.addEventListener('filterChanged', refresh);
});
onUnmounted(() => {
props.params.api.removeEventListener('rowDataUpdated', refresh);
props.params.api.removeEventListener('selectionChanged', refresh);
props.params.api.removeEventListener('filterChanged', refresh);
});
</script>
<template>
<div class="flex items-center h-[40px] gap-3 font-mono font-semibold" v-if="displayedRowCount > 0">
<span class="text-green-500">已选 {{ selectedRowCount }}/{{ displayedRowCount }}</span>
</div>
</template>
================================================
FILE: components/modal/Confirm.vue
================================================
<script setup lang="ts">
defineProps({
icon: {
type: String,
default: 'i-heroicons-solid:exclamation-triangle',
},
title: {
type: String,
},
description: {
type: String,
},
});
const modal = useModal();
const emit = defineEmits(['confirm', 'cancel']);
function onConfirm() {
emit('confirm');
modal.close();
}
function onCancel() {
emit('cancel');
modal.close();
}
</script>
<template>
<UModal prevent-close>
<UCard>
<div class="flex items-center gap-2 font-medium text-lg">
<UIcon :name="icon" class="size-10 text-rose-500" />
<span>{{ title }}</span>
</div>
<div v-if="description" class="my-5">{{ description }}</div>
<template #footer>
<div class="flex justify-end space-x-3">
<UButton color="white" class="px-3" @click="onCancel">取消</UButton>
<UButton color="rose" class="px-3" @click="onConfirm">确定</UButton>
</div>
</template>
</UCard>
</UModal>
</template>
================================================
FILE: components/modal/Login.vue
================================================
<script setup lang="ts">
import { request } from '#shared/utils/request';
import type { LoginAccount, ScanLoginResult, StartLoginResult } from '~/types/types';
const modal = useModal();
const qrcodeSrc = ref('');
const loading = ref(false);
const msg = ref('');
const checkTimer = ref<number | null>(null);
const loginAccount = useLoginAccount();
onMounted(() => {
getQrcode();
});
function closeModal() {
modal.close();
window.clearTimeout(checkTimer.value!);
checkTimer.value = null;
}
/**
* 创建新的登录会话
*
* 该请求会在response中设置一个唯一的uuid(cookie)作为会话id
*/
async function newLoginSession() {
const sid = new Date().getTime().toString() + Math.floor(Math.random() * 100);
const resp = await request<StartLoginResult>(`/api/web/login/session/${sid}`, { method: 'POST' });
if (!resp || !resp.base_resp || resp.base_resp.ret !== 0) {
throw new Error(`${resp?.base_resp?.err_msg || '获取登录会话失败'}`);
}
}
// 获取登录二维码
async function getQrcode() {
try {
loading.value = true;
msg.value = '获取登录二维码';
await newLoginSession();
qrcodeSrc.value = `/api/web/login/getqrcode?rnd=${Math.random()}`;
msg.value = '';
// 启动计时器开始轮训检查
_check();
} catch (e: any) {
msg.value = e.message;
qrcodeSrc.value = 'https://placehold.co/320?text=qrcode';
} finally {
loading.value = false;
}
}
function _check() {
window.clearTimeout(checkTimer.value!);
if (modal.isOpen.value) {
checkTimer.value = window.setTimeout(checkQrcodeStatus, 2000);
}
}
// 检查二维码扫描状态
async function checkQrcodeStatus() {
const resp = await request<ScanLoginResult>('/api/web/login/scan');
if (resp && resp.base_resp && resp.base_resp.ret === 0) {
switch (resp.status) {
case 0:
_check();
break;
case 1:
// 登录成功
msg.value = '已确认,正在登录中';
await bizLogin();
break;
case 2:
case 3:
// 刷新二维码
qrcodeSrc.value = `/api/web/login/getqrcode?rnd=${Math.random()}`;
_check();
break;
case 4:
case 6:
if (resp.acct_size >= 1) {
loading.value = true;
msg.value = '扫码成功,等待确认';
qrcodeSrc.value = '';
} else {
msg.value = '没有可登录账号';
}
_check();
break;
case 5:
// 未绑定邮箱,不能扫描登录
msg.value = '该账号尚未绑定邮箱';
_check();
break;
}
}
}
async function bizLogin() {
try {
loading.value = true;
const resp = await request<LoginAccount>('/api/web/login/bizlogin', {
method: 'POST',
});
if (resp.err) {
throw new Error(`${resp.err}`);
}
msg.value = '登录成功';
loginAccount.value = resp;
closeModal();
} catch (e: any) {
msg.value = e.message;
} finally {
loading.value = false;
}
}
</script>
<template>
<UModal prevent-close>
<UCard>
<template #header>
<h2 class="text-lg font-semibold">登录微信公众号</h2>
<UButton
square
variant="link"
color="gray"
icon="i-lucide:x"
class="absolute right-3 top-3"
@click="closeModal"
/>
</template>
<!-- 二维码图片展示区 -->
<div class="flex flex-col justify-center items-center mx-auto size-80">
<UIcon v-if="loading" name="i-lucide:loader" :size="28" class="animate-spin text-slate-500" />
<p v-if="msg" class="text-rose-500">{{ msg }}</p>
<img v-if="qrcodeSrc" :src="qrcodeSrc" alt="" class="w-full rounded-md" />
</div>
</UCard>
</UModal>
</template>
================================================
FILE: components/modal/QQGroup.vue
================================================
<script setup lang="ts">
import qqGroupImg from '~/assets/qq-group.png';
const modal = useModal();
function onClose() {
modal.close();
}
</script>
<template>
<UModal prevent-close>
<UCard>
<template #header>
<h2 class="text-lg font-semibold">加入 QQ 群</h2>
<UButton square variant="link" color="gray" class="absolute right-3 top-3" @click="onClose">
<UIcon name="i-lucide:x" class="size-6" />
</UButton>
</template>
<!-- 二维码图片展示区 -->
<div class="flex flex-col justify-center items-center mx-auto">
<img :src="qqGroupImg" alt="" class="size-72" />
<p class="text-2xl">群号: <span class="font-mono">991482155</span></p>
</div>
</UCard>
</UModal>
</template>
================================================
FILE: components/preview/Article.vue
================================================
<template>
<div>
<USlideover v-model="isOpen" :ui="{ width: 'max-w-[720px]' }">
<HtmlRenderer :html="articleHtml" v-model:show="isOpen" />
</USlideover>
</div>
</template>
<script setup lang="ts">
import { parseCgiDataNew } from '#shared/utils/html';
import { renderHTMLFromCgiDataNew } from '#shared/utils/renderer';
import HtmlRenderer from '~/components/preview/HtmlRenderer.vue';
import toastFactory from '~/composables/toast';
import usePreferences from '~/composables/usePreferences';
import { getHtmlCache, type HtmlAsset } from '~/store/v2/html';
import { getMetadataCache } from '~/store/v2/metadata';
import type { Preferences } from '~/types/preferences';
import type { AppMsgEx } from '~/types/types';
import { renderComments } from '~/utils/comment';
defineExpose({
open: open,
});
const toast = toastFactory();
const isOpen = ref(false);
const articleHtml = ref('');
async function open(article: AppMsgEx) {
const htmlAsset = await getHtmlCache(article.link);
if (htmlAsset) {
isOpen.value = true;
const rawHtml = await htmlAsset.file.text();
const cgiData = await parseCgiDataNew(rawHtml);
console.log(cgiData);
// articleHtml.value = await normalizeHtmlForPreview(htmlAsset, rawHtml);
articleHtml.value = await renderHTMLFromCgiDataNew(
cgiData,
(preferences.value as Preferences).exportConfig.exportHtmlIncludeComments
);
} else {
toast.warning('文章预览失败', `文章【${article.title}】还未拉取文章内容`);
}
}
const preferences: Ref<Preferences> = usePreferences() as unknown as Ref<Preferences>;
// 调整最终的 html
async function normalizeHtmlForPreview(cachedHtml: HtmlAsset, html: string): Promise<string> {
const parser = new DOMParser();
const document = parser.parseFromString(html, 'text/html');
const $jsArticleContent = document.querySelector('#js_article')!;
// #js_content 默认是不可见的(通过js修改为可见),需要移除该样式
$jsArticleContent.querySelector('#js_content')?.removeAttribute('style');
// 删除无用dom元素
$jsArticleContent.querySelector('#js_top_ad_area')?.remove();
$jsArticleContent.querySelector('#js_tags_preview_toast')?.remove();
$jsArticleContent.querySelector('#content_bottom_area')?.remove();
$jsArticleContent.querySelectorAll('script').forEach(el => {
el.remove();
});
$jsArticleContent.querySelector('#js_pc_qr_code')?.remove();
$jsArticleContent.querySelector('#wx_stream_article_slide_tip')?.remove();
let bodyCls = document.body.className;
// 渲染发布时间
function __setPubTime(oriTimestamp: number, dom: HTMLElement) {
const dateObj = new Date(oriTimestamp * 1000);
const padStart = function padStart(v: number) {
return '0'.concat(v.toString()).slice(-2);
};
const year = dateObj.getFullYear().toString();
const month = padStart(dateObj.getMonth() + 1);
const date = padStart(dateObj.getDate());
const hour = padStart(dateObj.getHours());
const minute = padStart(dateObj.getMinutes());
const timeString = ''.concat(hour, ':').concat(minute);
const dateString = ''.concat(year, '年').concat(month, '月').concat(date, '日');
const showDate = ''.concat(dateString, ' ').concat(timeString);
if (dom) {
dom.innerText = showDate;
}
}
const pubTimeMatchResult = html.match(/var oriCreateTime = '(?<date>\d+)'/);
if (pubTimeMatchResult && pubTimeMatchResult.groups && pubTimeMatchResult.groups.date) {
__setPubTime(parseInt(pubTimeMatchResult.groups.date), document.getElementById('publish_time')!);
}
// 渲染ip属地
function getIpWoridng(ipConfig: any) {
let ipWording = '';
if (parseInt(ipConfig.countryId, 10) === 156) {
ipWording = ipConfig.provinceName;
} else if (ipConfig.countryId) {
ipWording = ipConfig.countryName;
}
return ipWording;
}
const ipWrp = document.getElementById('js_ip_wording_wrp')!;
const ipWording = document.getElementById('js_ip_wording')!;
const ipWordingMatchResult = html.match(/window\.ip_wording = (?<data>{\s+countryName: '[^']+',[^}]+})/s);
if (ipWrp && ipWording && ipWordingMatchResult && ipWordingMatchResult.groups && ipWordingMatchResult.groups.data) {
const json = ipWordingMatchResult.groups.data;
eval('window.ip_wording = ' + json);
const ipWordingDisplay = getIpWoridng((window as any).ip_wording);
if (ipWordingDisplay !== '') {
ipWording.innerHTML = ipWordingDisplay;
ipWrp.style.display = 'inline-block';
}
}
// 渲染 标题已修改
function __setTitleModify(isTitleModified: boolean) {
const wrp = document.getElementById('js_title_modify_wrp')!;
const titleModifyNode = document.getElementById('js_title_modify')!;
if (!wrp) return;
if (isTitleModified) {
titleModifyNode.innerHTML = '标题已修改';
wrp.style.display = 'inline-block';
} else {
wrp.parentNode?.removeChild(wrp);
}
}
const titleModifiedMatchResult = html.match(/window\.isTitleModified = "(?<data>\d*)" \* 1;/);
if (titleModifiedMatchResult && titleModifiedMatchResult.groups && titleModifiedMatchResult.groups.data) {
__setTitleModify(titleModifiedMatchResult.groups.data === '1');
}
// 文章引用
const js_share_source = document.getElementById('js_share_source');
const contentTpl = document.getElementById('content_tpl');
if (js_share_source && contentTpl) {
const html = contentTpl.innerHTML
.replace(/<img[^>]*>/g, '<p>[图片]</p>')
.replace(
/<iframe [^>]*?class=\"res_iframe card_iframe js_editor_card\"[^>]*?data-cardid=[\'\"][^\'\"]*[^>]*?><\/iframe>/gi,
'<p>[卡券]</p>'
)
.replace(/<mpvoice([^>]*?)js_editor_audio([^>]*?)><\/mpvoice>/g, '<p>[语音]</p>')
.replace(/<mpgongyi([^>]*?)js_editor_gy([^>]*?)><\/mpgongyi>/g, '<p>[公益]</p>')
.replace(/<qqmusic([^>]*?)js_editor_qqmusic([^>]*?)><\/qqmusic>/g, '<p>[音乐]</p>')
.replace(/<mpshop([^>]*?)js_editor_shop([^>]*?)><\/mpshop>/g, '<p>[小店]</p>')
.replace(/<iframe([^>]*?)class=[\'\"][^\'\"]*video_iframe([^>]*?)><\/iframe>/g, '<p>[视频]</p>')
.replace(/(<iframe[^>]*?js_editor_vote_card[^<]*?<\/iframe>)/gi, '<p>[投票]</p>')
.replace(/<mp-weapp([^>]*?)weapp_element([^>]*?)><\/mp-weapp>/g, '<p>[小程序]</p>')
.replace(/<mp-miniprogram([^>]*?)><\/mp-miniprogram>/g, '<p>[小程序]</p>')
.replace(/<mpproduct([^>]*?)><\/mpproduct>/g, '<p>[商品]</p>')
.replace(/<mpcps([^>]*?)><\/mpcps>/g, '<p>[商品]</p>');
const div = document.createElement('div');
div.innerHTML = html;
let content = div.innerText;
content = content.replace(/</g, '<').replace(/>/g, '>').trim();
if (content.length > 140) {
content = content.substr(0, 140) + '...';
}
const digest = content.split('\n').map(function (line) {
return '<p>' + line + '</p>';
});
document.getElementById('js_content')!.innerHTML = digest.join('');
// 替换url
const sourceURL = js_share_source.getAttribute('data-url');
if (sourceURL) {
const link = document.createElement('a');
link.href = sourceURL;
link.className = js_share_source.className;
link.innerHTML = js_share_source.innerHTML;
js_share_source.replaceWith(link);
}
}
// 渲染阅读量
const metadata = await getMetadataCache(cachedHtml.url);
const $interaction_bar = document.querySelector('#js_article_bottom_bar .interaction_bar');
if ($interaction_bar) {
$interaction_bar.insertAdjacentHTML(
'afterbegin',
'<button id="js_temp_sns_sc_readnum_btn" aria-labelledby="js_a11y_zan_btn_txt readNum" style="-webkit-text-size-adjust: 100% ;" class="sns_opr_btn sns_view_btn weui-wa-hotarea js_wx_tap_highlight wx_tap_link">' +
`<span class="sns_opr_gap" aria-hidden="true" id="js_bar_readnum_btn">${metadata?.readNum}</span></button>`
);
}
// 渲染留言
let commentHTML = '';
if ((preferences.value as Preferences).exportConfig.exportHtmlIncludeComments) {
commentHTML = await renderComments(cachedHtml.url);
}
// 文本分享消息
const $js_text_desc = $jsArticleContent.querySelector('#js_text_desc') as HTMLElement | null;
if ($js_text_desc) {
// 文本分享页面的 body 额外样式
bodyCls += ' page_share_text';
// 顶部作者栏
const qmtplTextMatchResult = html.match(/(?<code>window\.__QMTPL_SSR_DATA__\s*=\s*\{.+?};)/s);
if (qmtplTextMatchResult && qmtplTextMatchResult.groups && qmtplTextMatchResult.groups.code) {
const code = qmtplTextMatchResult.groups.code;
// eslint-disable-next-line no-eval
eval(code);
const data = (window as any).__QMTPL_SSR_DATA__;
if (data && typeof data.title === 'string' && !$js_text_desc.innerHTML.trim()) {
let text = data.title as string;
text = text.replace(/\r/g, '').replace(/\n/g, '<br>');
$js_text_desc.innerHTML = text;
}
$jsArticleContent.querySelector('#js_top_profile')?.classList.remove('profile_area_hide');
}
// 正文内容
if (!$js_text_desc.innerHTML.trim()) {
const textContentMatch = html.match(
/var\s+TextContentNoEncode\s*=\s*window\.a_value_which_never_exists\s*\|\|\s*(?<value>'[^']*')/s
);
const contentMatch = html.match(
/var\s+ContentNoEncode\s*=\s*window\.a_value_which_never_exists\s*\|\|\s*(?<value>'[^']*')/s
);
let desc: string | null = null;
const assignFromMatch = (match: RegExpMatchArray | null, key: string) => {
if (match && match.groups && match.groups.value) {
const code = `window.${key} = ${match.groups.value}`;
// eslint-disable-next-line no-eval
eval(code);
// @ts-ignore
return (window as any)[key] as string;
}
return null;
};
desc = assignFromMatch(textContentMatch, '__WX_TEXT_NO_ENCODE__');
if (!desc) {
desc = assignFromMatch(contentMatch, '__WX_CONTENT_NO_ENCODE__');
}
if (desc) {
desc = desc.replace(/\r/g, '').replace(/\n/g, '<br>');
$js_text_desc.innerHTML = desc;
}
}
}
// 图片分享消息
const $js_image_desc = $jsArticleContent.querySelector('#js_image_desc');
if ($js_image_desc) {
bodyCls += 'pages_skin_pc page_share_img';
function decode_html(data: string, encode: boolean) {
const replace = [
''',
"'",
'"',
'"',
' ',
' ',
'>',
'>',
'<',
'<',
'¥',
'¥',
'&',
'&',
];
const replaceReverse = [
'&',
'&',
'¥',
'¥',
'<',
'<',
'>',
'>',
' ',
' ',
'"',
'"',
"'",
''',
];
let target = encode ? replaceReverse : replace;
let str = data;
for (let i = 0; i < target.length; i += 2) {
str = str.replace(new RegExp(target[i], 'g'), target[i + 1]);
}
return str;
}
const qmtplMatchResult = html.match(/(?<code>window\.__QMTPL_SSR_DATA__\s*=\s*\{.+?)<\/script>/s);
if (qmtplMatchResult && qmtplMatchResult.groups && qmtplMatchResult.groups.code) {
const code = qmtplMatchResult.groups.code;
eval(code);
const data = (window as any).__QMTPL_SSR_DATA__;
let desc = data.desc.replace(/\r/g, '').replace(/\n/g, '<br>').replace(/\s/g, ' ');
desc = decode_html(desc, false);
$js_image_desc.innerHTML = desc;
$jsArticleContent.querySelector('#js_top_profile')!.classList.remove('profile_area_hide');
}
const pictureMatchResult = html.match(/(?<code>window\.picture_page_info_list\s*=.+\.slice\(0,\s*20\);)/s);
if (pictureMatchResult && pictureMatchResult.groups && pictureMatchResult.groups.code) {
const code = pictureMatchResult.groups.code;
eval(code);
const picture_page_info_list = (window as any).picture_page_info_list;
const containerEl = $jsArticleContent.querySelector('#js_share_content_page_hd')!;
let innerHTML =
'<div style="display: flex;flex-direction: column;align-items: center;gap: 10px;padding-block: 20px;">';
for (const picture of picture_page_info_list) {
innerHTML += `<img src="${picture.cdn_url}" alt="" style="display: block;border: 1px solid gray;border-radius: 5px;max-width: 90%;" onclick="window.open(this.src, '_blank', 'popup')" />`;
}
innerHTML += '</div>';
containerEl.innerHTML = innerHTML;
}
}
// 去除图片的懒加载
const imgs = document.querySelectorAll<HTMLImageElement>('img');
for (const img of imgs) {
const imgUrl = img.getAttribute('src') || img.getAttribute('data-src');
if (imgUrl) {
img.src = imgUrl;
}
}
// 所有图片都替换完成后,重新解析得到最终的主内容和bottom内容
const newDocument = parser.parseFromString(document.documentElement.outerHTML, 'text/html');
const pageContentHTML = newDocument.querySelector('#js_article')!.outerHTML;
const jsArticleBottomBarHTML = newDocument.querySelector('#js_article_bottom_bar')?.outerHTML;
return `<!DOCTYPE html>
<html lang="zh_CN">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0,viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>${cachedHtml.title}</title>
<style>
#page-content,
#js_article_bottom_bar,
.__page_content__ {
max-width: 667px;
margin: 0 auto;
}
img {
max-width: 100%;
}
.sns_opr_btn::before {
width: 16px;
height: 16px;
margin-right: 3px;
}
</style>
</head>
<body class="${bodyCls}">
${pageContentHTML}
${jsArticleBottomBarHTML}
<!-- 评论数据 -->
${commentHTML}
</body>
</html>`;
}
</script>
================================================
FILE: components/preview/HtmlRenderer.vue
================================================
<template>
<div class="h-screen">
<UButton
icon="i-lucide:x"
square
variant="link"
color="gray"
class="absolute right-3 top-3"
@click="show = false"
></UButton>
<client-only>
<iframe class="border-none w-full h-screen" :srcdoc="htmlContent"></iframe>
</client-only>
</div>
</template>
<script lang="ts" setup>
import DOMPurify from 'dompurify';
interface Props {
html: string;
}
const props = defineProps<Props>();
const show = defineModel<boolean>('show', { default: false });
// 传入的完整HTML代码
const htmlContent = ref('');
watch(
() => props.html,
(newHtml: string) => {
// 使用DOMPurify来清理HTML内容,防止XSS攻击
htmlContent.value = DOMPurify.sanitize(newHtml, { WHOLE_DOCUMENT: true });
},
{ immediate: true }
);
</script>
================================================
FILE: components/search/AccountForm.vue
================================================
<template>
<form @submit.prevent="search">
<UInput
icon="i-heroicons-magnifying-glass-20-solid"
color="white"
required
v-model="query"
size="md"
:trailing="false"
placeholder="搜索公众号名称"
/>
</form>
</template>
<script setup lang="ts">
const query = defineModel<string>();
const emit = defineEmits(['search']);
function search() {
emit('search', query.value);
}
</script>
================================================
FILE: components/search/Article.vue
================================================
<template>
<form @submit.prevent="search">
<UInput
icon="i-heroicons-magnifying-glass-20-solid"
color="white"
v-model="query"
size="md"
class="focus-within:w-[300px] w-[200px] transition-all duration-300 ease-in-out"
:trailing="false"
:placeholder="'搜索文章标题 (' + metaSymbol + '+K)'"
ref="inputRef"
/>
</form>
</template>
<script setup lang="ts">
const query = defineModel<string>();
const emit = defineEmits(['search']);
const { metaSymbol } = useShortcuts();
function search() {
emit('search', query.value);
}
const inputRef = ref();
defineShortcuts({
meta_k: {
usingInput: true,
handler: () => {
inputRef.value.input.focus();
},
},
escape: {
usingInput: true,
handler: () => {
inputRef.value.input.blur();
},
},
});
</script>
================================================
FILE: components/selector/AccountSelectorForAlbum.vue
================================================
<template>
<USelectMenu
v-model="selected"
size="md"
color="gray"
searchable
searchable-placeholder="筛选公众号..."
clear-search-on-close
:options="sortedAccountInfos"
option-attribute="nickname"
placeholder="请选择公众号"
>
<template #label>
<UAvatar v-if="selected" :src="IMAGE_PROXY + selected.round_head_img" size="2xs" />
<span v-if="selected" class="max-w-30 line-clamp-1">{{ selected.nickname }}</span>
<span v-if="selected" class="shrink-0">({{ selected.albums!.length }}个合集)</span>
</template>
<template #option="{ option: account }">
<UAvatar :src="IMAGE_PROXY + account.round_head_img" size="sm" />
<div>
<p class="text-[16px]">{{ account.nickname }}</p>
<p class="text-gray-500 text-sm">合集数: {{ account.albums.length }}</p>
</div>
</template>
<template #option-empty="{ query }">
未找到匹配「{{ query }}」的公众号<br />请先在「<NuxtLink
to="/dashboard/account"
class="text-blue-500 hover:underline"
>公众号管理</NuxtLink
>」中添加
</template>
<template #empty>
暂无公众号,请先在「<NuxtLink to="/dashboard/account" class="text-blue-500 hover:underline">公众号管理</NuxtLink
>」中添加
</template>
</USelectMenu>
</template>
<script setup lang="ts">
import { IMAGE_PROXY } from '~/config';
import { getArticleCache } from '~/store/v2/article';
import { getAllInfo, type MpAccount } from '~/store/v2/info';
import type { AppMsgAlbumInfo } from '~/types/types';
interface AccountInfo extends MpAccount {
albums?: AppMsgAlbumInfo[];
}
// 已缓存的公众号信息
const cachedAccountInfos: AccountInfo[] = reactive(await getAllInfo());
cachedAccountInfos.forEach(async accountInfo => {
accountInfo.albums = await getAllAlbums(accountInfo.fakeid);
});
const sortedAccountInfos = computed(() => {
cachedAccountInfos.sort((a, b) => {
if (a.albums && b.albums) {
return a.albums.length > b.albums.length ? -1 : 1;
} else {
return 0;
}
});
return cachedAccountInfos;
});
// 获取公众号下所有的合集数据(根据已缓存的文章数据)
async function getAllAlbums(fakeid: string) {
const articles = await getArticleCache(fakeid, Math.floor(Date.now() / 1000));
const albums: AppMsgAlbumInfo[] = [];
articles
.flatMap(article => article.appmsg_album_infos)
.forEach(album => {
if (!albums.some(a => a.id === album.id)) {
albums.push(album);
}
});
return albums;
}
const selected = defineModel<AccountInfo | undefined>();
</script>
================================================
FILE: components/selector/AccountSelectorForArticle.vue
================================================
<template>
<USelectMenu
v-model="selected"
size="md"
color="gray"
searchable
searchable-placeholder="筛选公众号..."
clear-search-on-close
:options="sortedAccountInfos"
option-attribute="nickname"
placeholder="请选择公众号"
>
<template #label>
<UAvatar v-if="selected" :src="selected.round_head_img" size="2xs" />
<span v-if="selected" class="max-w-30 line-clamp-1">{{ selected.nickname }}</span>
<span v-if="selected" class="shrink-0">({{ selected.articles }}篇)</span>
</template>
<template #option="{ option: account }">
<UAvatar :src="account.round_head_img" size="sm" />
<div>
<p class="text-[16px]">{{ account.nickname }}</p>
<p class="text-gray-500 text-sm">已加载文章数: {{ account.articles }}</p>
</div>
</template>
<template #option-empty="{ query }">
未找到匹配「{{ query }}」的公众号<br />请先在「<NuxtLink
to="/dashboard/account"
class="text-blue-500 hover:underline"
>公众号管理</NuxtLink
>」中添加
</template>
<template #empty>
暂无公众号,请先在「<NuxtLink to="/dashboard/account" class="text-blue-500 hover:underline">公众号管理</NuxtLink
>」中添加
</template>
</USelectMenu>
</template>
<script setup lang="ts">
import { getAllInfo, type MpAccount } from '~/store/v2/info';
// 已缓存的公众号信息
const cachedAccountInfos = await getAllInfo();
const sortedAccountInfos = computed(() => {
cachedAccountInfos.sort((a, b) => {
return a.articles > b.articles ? -1 : 1;
});
return cachedAccountInfos;
});
const selected = defineModel<MpAccount | undefined>();
</script>
================================================
FILE: components/setting/Display.vue
================================================
<template>
<UCard class="mx-4 mt-10 flex-1">
<template #header>
<h3 class="text-2xl font-semibold">显示</h3>
<p class="text-sm text-slate-10 font-serif">配置文章下载页面的显示选项</p>
</template>
<div class="flex flex-col space-y-5"></div>
</UCard>
</template>
<script setup lang="ts"></script>
================================================
FILE: components/setting/Export.vue
================================================
<template>
<UCard class="mx-4 mt-10 flex-1">
<template #header>
<h3 class="text-2xl font-semibold">导出选项</h3>
<p class="text-sm text-slate-10 font-serif">配置文章的导出选项</p>
</template>
<div class="flex flex-col space-y-5">
<div>
<p class="mb-2">
<span class="mr-3">导出目录名:</span>
<span class="inline-block w-8">
<UPopover mode="hover" :popper="{ placement: 'right' }">
<UButton color="white" size="sm" trailing-icon="i-heroicons:variable-16-solid" />
<template #panel>
<div class="p-4">
<p class="my-2 font-medium">支持的变量:</p>
<table class="w-full border-collapse border">
<tbody>
<tr>
<th class="w-20">变量</th>
<th class="w-32">含义</th>
<th class="w-20">变量</th>
<th class="w-32">含义</th>
</tr>
<tr v-for="(item, idx) in variables" :key="idx">
<td class="text-center">{{ item[0].name }}</td>
<td class="text-center">{{ item[0].description }}</td>
<td class="text-center">{{ item[1].name }}</td>
<td class="text-center">{{ item[1].description }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</UPopover>
</span>
</p>
<p class="text-sm mb-2 text-gray-500">影响 <span class="font-mono">html/txt/markdown/word/pdf</span> 的导出</p>
<UInput
placeholder="目录名格式"
class="w-[600px] font-mono"
name="dirname"
v-model="preferences.exportConfig.dirname"
/>
</div>
<div>
<p class="mb-2 flex items-center gap-3">
<span>目录名最大长度:</span>
<span class="text-xs text-gray-500">(0表示不限制)</span>
<UInput
class=""
placeholder="目录名最大长度"
v-model="preferences.exportConfig.maxlength"
type="number"
min="0"
/>
</p>
</div>
<div>
<UCheckbox
v-model="preferences.exportConfig.exportExcelIncludeContent"
name="exportExcelIncludeContent"
label="导出 Excel 中包含文章内容"
/>
</div>
<div>
<UCheckbox
v-model="preferences.exportConfig.exportJsonIncludeContent"
name="exportJsonIncludeContent"
label="导出 JSON 中包含文章内容"
/>
<UCheckbox
v-model="preferences.exportConfig.exportJsonIncludeComments"
name="exportJsonIncludeComments"
label="导出 JSON 中包含留言数据"
/>
</div>
<div>
<UCheckbox
v-model="preferences.exportConfig.exportHtmlIncludeComments"
name="exportHtmlIncludeComments"
label="导出 HTML 中包含留言数据"
/>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { Preferences } from '~/types/preferences';
const preferences: Ref<Preferences> = usePreferences() as unknown as Ref<Preferences>;
const _variables = [
{ name: 'account', description: '公众号名称' },
{ name: 'title', description: '文章标题' },
{ name: 'aid', description: '文章id' },
{ name: 'author', description: '作者' },
{ name: 'YYYY', description: '年' },
{ name: 'MM', description: '月' },
{ name: 'DD', description: '日' },
{ name: 'HH', description: '时' },
{ name: 'mm', description: '分' },
];
const variables = Array.from({ length: Math.ceil(_variables.length / 2) }, (_, i) => [
_variables[i * 2] ?? {},
_variables[i * 2 + 1] ?? {},
]);
</script>
<style scoped>
table th {
padding: 0.5rem 0.25rem;
}
table td {
border: 1px solid #00002d17;
padding: 0.25rem 0.5rem;
}
td:first-child,
th:first-child {
border-left: none;
}
td:last-child,
th:last-child {
border-right: none;
}
th {
border: 1px solid #00002d17;
border-top: none;
}
tr:nth-child(even) {
background-color: #00005506;
}
tr:hover {
background-color: #00005506;
}
</style>
================================================
FILE: components/setting/Misc.vue
================================================
<template>
<UCard class="mx-4 mt-10 flex-1">
<template #header>
<h3 class="text-2xl font-semibold">其他</h3>
</template>
<div class="flex">
<div class="flex-1 flex flex-col space-y-3">
<div class="flex gap-1">
<UCheckbox v-model="preferences.hideDeleted" name="hideDeleted" label="隐藏已删除文章" />
<UPopover mode="hover" :popper="{ placement: 'top' }">
<template #panel>
<p class="max-w-[300px] p-3 text-sm text-gray-500">
是否在文章下载表格中显示已删除的文章。<br />
若勾选该选项,则文章下载表格将过滤掉已经被删除的文章(无论文章内容是否已被下载)。
</p>
</template>
<UIcon color="gray" name="i-heroicons:question-mark-circle-16-solid" class="size-5" />
</UPopover>
</div>
<div class="flex gap-1">
<UCheckbox
v-model="preferences.downloadConfig.forceDownloadContent"
name="forceDownloadContent"
label="强制下载文章内容"
/>
<UPopover mode="hover" :popper="{ placement: 'top' }">
<template #panel>
<p class="max-w-[300px] p-3 text-sm text-gray-500">
在抓取文章内容时,若该文章内容已被下载,则会跳过抓取过程。<br />
若勾选该选项,则会忽略已缓存内容,强制重新下载最新文章内容。<br />
</p>
</template>
<UIcon color="gray" name="i-heroicons:question-mark-circle-16-solid" class="size-5" />
</UPopover>
</div>
<div class="flex gap-1">
<UCheckbox
v-model="preferences.downloadConfig.metadataOverrideContent"
name="metadataOverrideContent"
label="抓取阅读量时是否覆盖文章内容"
/>
<UPopover mode="hover" :popper="{ placement: 'top' }">
<template #panel>
<p class="max-w-[300px] p-3 text-sm text-gray-500">
在抓取阅读量时,会同时下载文章内容。<br />
若勾选该选项,则文章内容会同时保存到缓存中(会占用一定的存储空间)。
</p>
</template>
<UIcon color="gray" name="i-heroicons:question-mark-circle-16-solid" class="size-5" />
</UPopover>
</div>
</div>
<div class="flex-1">
<div>
<p class="flex">
<span class="text-sm">公众号同步频率:</span>
<UPopover mode="hover" :popper="{ placement: 'top' }">
<template #panel>
<p class="max-w-[300px] p-3 text-sm text-gray-500">
在同步公众号文章数据时,程序会自动抓取该公众号的所有文章,直到所有数据同步完成。<br />
该选项用于控制抓取频率,比如设置为 5
就表示每五秒抓取一次。该数据越小,同步的越快,但是容易被封号。推荐不小于3
</p>
</template>
<UIcon color="gray" name="i-heroicons:question-mark-circle-16-solid" class="size-5" />
</UPopover>
</p>
<UInput
type="number"
v-model="preferences.accountSyncSeconds"
placeholder="配置公众号同步频率"
class="w-52 font-mono"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">秒</span>
</template>
</UInput>
</div>
</div>
</div>
<div class="border border-slate-200 p-3 rounded-md mt-5">
<p class="flex justify-between items-center mb-3">
<span class="text-xl font-medium">
同步时间范围:
<span class="text-xs text-slate-500">(说明: 只能从当前时间开始往前同步)</span>
</span>
<span class="text-sm text-blue-500 font-medium">实际同步范围: {{ getActualDateRange() }}</span>
</p>
<div class="flex gap-3">
<USelectMenu
class="w-1/2"
v-model="preferences.syncDateRange"
:options="DURATION_OPTIONS"
value-attribute="value"
option-attribute="label"
/>
<UPopover v-if="preferences.syncDateRange === 'point'" :popper="{ placement: 'bottom-start' }">
<UButton color="gray" icon="i-heroicons-calendar-days-20-solid" :label="formatDate()" />
<template #panel="{ close }">
<BaseDatePicker v-model="preferences.syncDatePoint" is-required @close="close" />
</template>
</UPopover>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import type { Preferences } from '~/types/preferences';
const { getActualDateRange, getSelectOptions } = useSyncDeadline();
const preferences: Ref<Preferences> = usePreferences() as unknown as Ref<Preferences>;
const DURATION_OPTIONS = getSelectOptions();
function formatDate() {
return dayjs.unix(preferences.value.syncDatePoint).format('YYYY-MM-DD');
}
</script>
================================================
FILE: components/setting/Proxy.vue
================================================
<template>
<UCard class="mx-4 mt-10">
<template #header>
<h3 class="text-2xl font-semibold">代理节点</h3>
<p class="text-sm text-slate-10 font-serif">
若此处留空,则网站将使用
<ExternalLink :href="docsWebSite + '/get-started/proxy.html'" text="公共代理" /> 进行资源下载。
</p>
<p>
<ExternalLink :href="docsWebSite + '/get-started/private-proxy.html'" text="如何搭建代理节点?" />
</p>
</template>
<div class="flex space-x-10">
<textarea
class="h-[400px] flex-1 p-2 border rounded resize-none font-mono"
v-model="textareaValue"
spellcheck="false"
placeholder="请填写私有部署的代理地址,一行一个"
></textarea>
<div class="flex-1 flex-shrink-0">
<div class="my-5">
<p>代理节点地址要求:</p>
<ol>
<li>
<p>1. 以 <code class="text-rose-500 font-mono">http/https</code> 开头的绝对路径地址。</p>
<p>
2. 该地址在使用时后面会自动拼接
<code class="text-rose-500 font-mono">?url=</code> 等参数,请确保格式正确。
</p>
</li>
</ol>
<p class="mt-3">代理示例:</p>
<p><code class="text-rose-500 font-mono">https://wproxy-01.deno.dev</code></p>
<p><code class="text-rose-500 font-mono">https://wproxy-01.deno.dev/</code></p>
</div>
<UButton type="submit" @click="save" color="black" class="w-20 mt-5 justify-center disabled:bg-slate-10">{{
saveBtnText
}}</UButton>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import ExternalLink from '~/components/base/ExternalLink.vue';
import { docsWebSite } from '~/config';
import type { Preferences } from '~/types/preferences';
const preferences: Ref<Preferences> = usePreferences() as unknown as Ref<Preferences>;
const textareaValue = ref('');
const proxyList = computed(() => {
return textareaValue.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0 && line.startsWith('http'));
});
onMounted(() => {
try {
const configuredProxyList = (preferences.value as Preferences).privateProxyList;
if (configuredProxyList.length > 0) {
textareaValue.value = configuredProxyList.join('\n');
}
} catch (e) {}
});
const saveBtnText = ref('保存');
async function save() {
saveBtnText.value = '保存成功';
setTimeout(() => {
(preferences.value as Preferences).privateProxyList = proxyList.value;
saveBtnText.value = '保存';
}, 1000);
}
</script>
================================================
FILE: composables/toast.ts
================================================
export default () => {
const toast = useToast();
function success(title: string, description: string = '') {
toast.add({
color: 'sky',
title: title,
description: description,
icon: 'i-lucide:sparkles',
timeout: 5000,
});
}
function info(title: string, description: string = '') {
toast.add({
color: 'gray',
title: title,
description: description,
icon: 'i-lucide:bell',
timeout: 5000,
});
}
function warning(title: string, description: string = '') {
toast.add({
color: 'orange',
title: title,
description: description,
icon: 'i-lucide:triangle-alert',
timeout: 5000,
});
}
function error(title: string, description: string = '') {
toast.add({
color: 'rose',
title: title,
description: description,
icon: 'i-lucide:skull',
timeout: 5000,
});
}
return {
info,
warning,
success,
error,
};
};
================================================
FILE: composables/useAccountEventBus.ts
================================================
import { type EventBusKey, useEventBus } from '@vueuse/core';
const accountEventKey: EventBusKey<string> = Symbol('account-event');
// 统一的账号相关事件,用于不同组件之间同步状态
type AccountEvent = 'account-added' | 'account-removed';
interface AccountEventPayload {
fakeid: string;
}
export default () => {
const accountEventBus = useEventBus<AccountEvent, AccountEventPayload>(accountEventKey);
return {
accountEventBus,
};
};
================================================
FILE: composables/useBatchDownload.ts
================================================
import { format } from 'date-fns';
import { saveAs } from 'file-saver';
import JSZip from 'jszip';
import type { DownloadableArticle } from '~/types/types';
import { downloadArticleHTMLs, packHTMLAssets } from '~/utils';
/**
* 批量下载合集文章
*/
export function useDownloadAlbum() {
const loading = ref(false);
const phase = ref();
const downloadedCount = ref(0);
const packedCount = ref(0);
async function download(articles: DownloadableArticle[], filename: string) {
loading.value = true;
try {
phase.value = '下载文章内容';
const results = await downloadArticleHTMLs(articles, (count: number) => {
downloadedCount.value = count;
});
phase.value = '打包';
const zip = new JSZip();
for (const article of results) {
await packHTMLAssets(
article.fakeid,
article.html!,
article.title.replaceAll('.', '_'),
zip.folder(format(new Date(+article.date * 1000), 'yyyy-MM-dd') + ' ' + article.title.replace(/\//g, '_'))!
);
packedCount.value++;
}
const blob = await zip.generateAsync({ type: 'blob' });
saveAs(blob, `${filename}.zip`);
} catch (e: any) {
alert(e.message);
console.error(e);
} finally {
loading.value = false;
}
}
return {
loading,
phase,
downloadedCount,
packedCount,
download,
};
}
================================================
FILE: composables/useDownloader.ts
================================================
import { formatElapsedTime } from '#shared/utils/helpers';
import toastFactory from '~/composables/toast';
import type { Metadata } from '~/store/v2/metadata';
import { Downloader } from '~/utils/download/Downloader';
import type { DownloaderStatus } from '~/utils/download/types';
export interface DownloadArticleOptions {
// 文章内容下载成功回调
onContent: (url: string) => void;
// 文章状态异常回调(不含「已删除」)
onStatusChange: (url: string, status: string) => void;
// 文章被删除回调
onDelete: (url: string) => void;
// 文章阅读量抓取成功回调
onMetadata: (url: string, metadata: Metadata) => void;
// 文章留言抓取成功回调
onComment: (url: string) => void;
// 修复单篇文章下载的 fakeid 专用
onFakeID: (url: string, fakeid: string) => void;
}
export default (options: Partial<DownloadArticleOptions> = {}) => {
const toast = toastFactory();
const loading = ref(false);
const completed_count = ref(0);
const total_count = ref(0);
let downloader: Downloader | null = null;
// 抓取文章内容(html)
async function downloadArticleHTML(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
try {
loading.value = true;
cleanupDownloader();
downloader = new Downloader(urls);
downloader.on('download:progress', (url: string, success: boolean, status: DownloaderStatus) => {
console.debug(
`进度: (进行中:${status.pending.length} / 已完成:${status.completed.length} / 已失败:${status.failed.length} / 已删除:${status.deleted.length})`
);
completed_count.value = status.completed.length;
if (success && typeof options.onContent === 'function') {
options.onContent(url);
}
});
downloader.on('download:deleted', (url: string) => {
if (typeof options.onDelete === 'function') {
options.onDelete(url);
}
});
downloader.on('download:exception', (url: string, msg: string) => {
if (typeof options.onStatusChange === 'function') {
options.onStatusChange(url, msg);
}
});
downloader.on('download:begin', () => {
console.debug('开始抓取【文章内容】...');
completed_count.value = 0;
total_count.value = urls.length;
});
downloader.on('download:finish', (seconds: number, status: DownloaderStatus) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success(
'【文章内容】抓取完成',
`本次抓取耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}, 检测到已被删除:${status.deleted.length}`
);
});
downloader.on('download:stop', () => {
toast.info('HTML下载任务已停止');
});
await downloader.startDownload('html');
} catch (error) {
console.error('【文章内容】抓取失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
cleanupDownloader();
}
}
// 抓取文章阅读量、点赞量等元数据
async function downloadArticleMetadata(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
try {
loading.value = true;
cleanupDownloader();
downloader = new Downloader(urls);
downloader.on('download:progress', (url: string, success: boolean, status: DownloaderStatus) => {
console.debug(
`进度: (进行中:${status.pending.length} / 已完成:${status.completed.length} / 已失败:${status.failed.length} / 已删除:${status.deleted.length})`
);
completed_count.value = status.completed.length;
});
downloader.on('download:metadata', (url: string, metadata: Metadata) => {
if (typeof options.onMetadata === 'function') {
options.onMetadata(url, metadata);
}
});
downloader.on('download:deleted', (url: string) => {
if (typeof options.onDelete === 'function') {
options.onDelete(url);
}
});
downloader.on('download:exception', (url: string, msg: string) => {
if (typeof options.onStatusChange === 'function') {
options.onStatusChange(url, msg);
}
});
downloader.on('download:begin', () => {
console.debug('开始抓取【阅读量】...');
completed_count.value = 0;
total_count.value = urls.length;
});
downloader.on('download:finish', (seconds: number, status: DownloaderStatus) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success(
'【阅读量】抓取完成',
`本次抓取耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}, 检测到已被删除:${status.deleted.length}`
);
});
await downloader.startDownload('metadata');
} catch (error) {
console.error('【阅读量】抓取失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
cleanupDownloader();
}
}
// 抓取文章留言数据
async function downloadArticleComment(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
try {
loading.value = true;
cleanupDownloader();
downloader = new Downloader(urls);
downloader.on('download:progress', (url: string, success: boolean, status: DownloaderStatus) => {
console.debug(
`进度: (进行中:${status.pending.length} / 已完成:${status.completed.length} / 已失败:${status.failed.length} / 已删除:${status.deleted.length})`
);
completed_count.value = status.completed.length;
if (success && typeof options.onComment === 'function') {
options.onComment(url);
}
});
downloader.on('download:begin', () => {
console.debug('开始抓取【留言内容】...');
completed_count.value = 0;
total_count.value = urls.length;
});
downloader.on('download:finish', (seconds: number, status: DownloaderStatus) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success(
'【留言内容】抓取完成',
`本次抓取耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}`
);
});
await downloader.startDownload('comments');
} catch (error) {
console.error('【留言内容】抓取失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
cleanupDownloader();
}
}
// 修复单篇文章fakeid
async function fixSingleFakeidTask(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
try {
loading.value = true;
cleanupDownloader();
downloader = new Downloader(urls);
downloader.on('download:progress', (url: string, success: boolean, status: DownloaderStatus) => {
console.debug(
`进度: (进行中:${status.pending.length} / 已完成:${status.completed.length} / 已失败:${status.failed.length})`
);
completed_count.value = status.completed.length;
});
downloader.on('download:begin', () => {
console.debug('开始修复 fakeid ...');
completed_count.value = 0;
total_count.value = urls.length;
});
downloader.on('fix:fakeid', (url: string, fakeid: string) => {
console.debug(`${url} 修复成功 fakeid: ${fakeid}`);
if (typeof options.onFakeID === 'function') {
options.onFakeID(url, fakeid);
}
});
downloader.on('download:finish', (seconds: number, status: DownloaderStatus) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success(
'【fakeid】修复完成',
`本次耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}`
);
});
await downloader.startDownload('fakeid');
} catch (error) {
console.error('【fakeid】修复失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
cleanupDownloader();
}
}
async function download(type: 'html' | 'metadata' | 'comment' | 'fakeid', urls: string[]) {
if (type === 'html') {
await downloadArticleHTML(urls);
} else if (type === 'metadata') {
await downloadArticleMetadata(urls);
} else if (type === 'comment') {
await downloadArticleComment(urls);
} else if (type === 'fakeid') {
await fixSingleFakeidTask(urls);
}
}
function cleanupDownloader() {
if (downloader) {
downloader.removeAllListeners();
downloader = null;
}
}
function stop() {
if (downloader) {
downloader.stop();
// 注意:不在此处清理监听器,等 download:stop 事件触发后由 finally 块清理
}
}
return {
loading,
completed_count,
total_count,
download,
stop,
};
};
================================================
FILE: composables/useExporter.ts
================================================
import { formatElapsedTime } from '#shared/utils/helpers';
import toastFactory from '~/composables/toast';
import { Exporter } from '~/utils/download/Exporter';
import type { ExporterStatus } from '~/utils/download/types';
export default () => {
const toast = toastFactory();
const loading = ref(false);
const phase = ref('导出中');
const completed_count = ref(0);
const total_count = ref(0);
// 导出 excel
async function export2excel(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
const manager = new Exporter(urls);
manager.on('export:begin', () => {
phase.value = '导出中';
completed_count.value = 0;
total_count.value = 0;
});
manager.on('export:total', (total: number) => {
total_count.value = total;
});
manager.on('export:progress', (num: number) => {
completed_count.value = num;
});
manager.on('export:finish', (seconds: number) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success('Excel 导出完成', `本次导出耗时 ${formatElapsedTime(seconds)}`);
});
try {
loading.value = true;
await manager.startExport('excel');
} catch (error) {
console.error('导出任务失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
}
}
// 导出 json
async function export2json(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
const manager = new Exporter(urls);
manager.on('export:begin', () => {
phase.value = '导出中';
completed_count.value = 0;
total_count.value = 0;
});
manager.on('export:total', (total: number) => {
total_count.value = total;
});
manager.on('export:progress', (num: number) => {
completed_count.value = num;
});
manager.on('export:finish', (seconds: number) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success('Json 导出完成', `本次导出耗时 ${formatElapsedTime(seconds)}`);
});
try {
loading.value = true;
await manager.startExport('json');
} catch (error) {
console.error('导出任务失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
}
}
// 导出 html
async function export2html(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
const manager = new Exporter(urls);
manager.on('export:begin', () => {
phase.value = '资源解析中';
completed_count.value = 0;
total_count.value = 0;
});
manager.on('export:download', (total: number) => {
phase.value = '资源下载中';
completed_count.value = 0;
total_count.value = total;
});
manager.on('export:download:progress', (url: string, success: boolean, status: ExporterStatus) => {
completed_count.value = status.completed.length;
});
manager.on('export:write', (total: number) => {
phase.value = '文件写入中';
completed_count.value = 0;
total_count.value = total;
});
manager.on('export:write:progress', (index: number) => {
completed_count.value = index;
});
manager.on('export:finish', (seconds: number) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success('HTML 导出完成', `本次导出耗时 ${formatElapsedTime(seconds)}`);
});
try {
loading.value = true;
await manager.startExport('html');
} catch (error) {
console.error('导出任务失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
}
}
// 导出 txt
async function export2txt(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
const manager = new Exporter(urls);
manager.on('export:begin', () => {
phase.value = '资源解析中';
completed_count.value = 0;
total_count.value = 0;
});
manager.on('export:total', (total: number) => {
phase.value = '导出中';
completed_count.value = 0;
total_count.value = total;
});
manager.on('export:progress', (index: number) => {
completed_count.value = index;
});
manager.on('export:finish', (seconds: number) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success('Txt 导出完成', `本次导出耗时 ${formatElapsedTime(seconds)}`);
});
try {
loading.value = true;
await manager.startExport('txt');
} catch (error) {
console.error('导出任务失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
}
}
// 导出 markdown
async function export2markdown(urls: string[]) {
if (urls.length === 0) {
toast.success('提示', '请先选择文章');
return;
}
const manager = new Exporter(urls);
manager.on('export:begin', () => {
phase.value = '资源解析中';
completed_count.value = 0;
total_count.value = 0;
});
manager.on('export:total', (total: number) => {
phase.value = '导出中';
completed_count.value = 0;
total_count.value = total;
});
manager.on('export:progress', (index: number) => {
completed_count.value = index;
});
manager.on('export:finish', (seconds: number) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success('Markdown 导出完成', `本次导出耗时 ${formatElapsedTime(seconds)}`);
});
try {
loading.value = true;
await manager.startExport('markdown');
} catch (error) {
console.error('导出任务失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
}
}
// 导出 word
async function export2word(urls: string[]) {
if (urls.length === 0) {
toast.warning('提示', '请先选择文章');
return;
}
const manager = new Exporter(urls);
manager.on('export:begin', () => {
phase.value = '资源解析中';
completed_count.value = 0;
total_count.value = 0;
});
manager.on('export:total', (total: number) => {
phase.value = '导出中';
completed_count.value = 0;
total_count.value = total;
});
manager.on('export:progress', (index: number) => {
completed_count.value = index;
});
manager.on('export:finish', (seconds: number) => {
console.debug('耗时:', formatElapsedTime(seconds));
toast.success('Word 导出完成', `本次导出耗时 ${formatElapsedTime(seconds)}`);
});
try {
loading.value = true;
await manager.startExport('word');
} catch (error) {
console.error('导出任务失败:', error);
alert((error as Error).message);
} finally {
loading.value = false;
}
}
// 导出 pdf
async function export2pdf(urls: string[]) {}
const needsContentFormats = new Set(['html', 'text', 'markdown', 'word']);
function exportFile(
type: 'excel' | 'json' | 'html' | 'text' | 'markdown' | 'word' | 'pdf',
urls: string[],
contentNotDownloadedCount?: number,
) {
if (needsContentFormats.has(type) && contentNotDownloadedCount) {
toast.warning('提示', `有 ${contentNotDownloadedCount} 篇文章尚未抓取内容,请先抓取内容后再导出`);
return;
}
switch (type) {
case 'excel':
return export2excel(urls);
case 'json':
return export2json(urls);
case 'html':
return export2html(urls);
case 'text':
return export2txt(urls);
case 'markdown':
return export2markdown(urls);
case 'word':
return export2word(urls);
case 'pdf':
return export2pdf(urls);
}
}
return {
loading,
phase,
completed_count,
total_count,
exportFile,
};
};
================================================
FILE: composables/useLoginAccount.ts
================================================
import { StorageSerializers } from '@vueuse/core';
import type { LoginAccount } from '~/types/types';
export default () => {
return useLocalStorage<LoginAccount>('login', null, {
serializer: StorageSerializers.object,
});
};
================================================
FILE: composables/useLoginCheck.ts
================================================
import LoginModal from '~/components/modal/Login.vue';
export default () => {
const modal = useModal();
const loginAccount = useLoginAccount();
// 检查是否有登录信息
function checkLogin() {
if (loginAccount.value === null) {
modal.open(LoginModal);
return false;
}
return true;
}
return {
checkLogin,
};
};
================================================
FILE: composables/usePreferences.ts
================================================
import { StorageSerializers } from '@vueuse/core';
import { MP_ORIGIN_TIMESTAMP } from '~/config';
import type { Preferences } from '~/types/preferences';
const defaultOptions: Partial<Preferences> = {
hideDeleted: true,
privateProxyList: [],
privateProxyAuthorization: '',
exportConfig: {
dirname: '${title}',
maxlength: 0,
exportExcelIncludeContent: true,
exportJsonIncludeComments: true,
exportJsonIncludeContent: true,
exportHtmlIncludeComments: true,
},
downloadConfig: {
forceDownloadContent: false,
metadataOverrideContent: false,
},
accountSyncSeconds: 3,
syncDateRange: 'all',
syncDatePoint: MP_ORIGIN_TIMESTAMP,
};
export default () => {
//@ts-ignore
return useLocalStorage<Preferences>('preferences', defaultOptions, {
serializer: StorageSerializers.object,
mergeDefaults: true,
});
};
================================================
FILE: composables/useSyncDeadline.ts
================================================
import dayjs, { Dayjs } from 'dayjs';
import { MP_ORIGIN_TIMESTAMP } from '~/config';
import type { Preferences } from '~/types/preferences';
export default () => {
const preferences = usePreferences();
function getDeadline(): Dayjs {
const syncDateRange = (preferences.value as unknown as Preferences).syncDateRange;
const syncDatePoint = (preferences.value as unknown as Preferences).syncDatePoint;
const start = dayjs().add(1, 'days').startOf('day');
switch (syncDateRange) {
case '1d':
return start.subtract(1, 'days');
case '3d':
return start.subtract(3, 'days');
case '7d':
return start.subtract(7, 'days');
case '1m':
return start.subtract(1, 'months');
case '3m':
return start.subtract(3, 'months');
case '6m':
return start.subtract(6, 'months');
case '1y':
return start.subtract(1, 'years');
case 'point':
// 指定绝对时间
if (syncDatePoint === 0) {
// 等价于all
return dayjs.unix(MP_ORIGIN_TIMESTAMP);
} else {
return dayjs.unix(syncDatePoint);
}
case 'all':
default:
return dayjs.unix(MP_ORIGIN_TIMESTAMP);
}
}
/**
* 获取文章同步的截止时间戳
*
* @description 该时间戳会与文章的发布时间(create_time)进行比对,若文章的发布时间早于该值,则不再继续同步该公众号
*/
function getSyncTimestamp() {
return getDeadline().unix();
}
function getActualDateRange() {
const now = dayjs().format('YYYY-MM-DD');
const deadline = getDeadline();
return now + ' ~ ' + deadline.format('YYYY-MM-DD');
}
/**
* 获取同步范围设置项
*/
function getSelectOptions() {
return [
{
value: '1d',
label: '最近一天',
},
{
value: '3d',
label: '最近三天',
},
{
value: '7d',
label: '最近七天',
},
{
value: '1m',
label: '最近一个月',
},
{
value: '3m',
label: '最近三个月',
},
{
value: '6m',
label: '最近半年',
},
{
value: '1y',
label: '最近一年',
},
{
value: 'all',
label: '全部',
},
{
value: 'point',
label: '自定义时间',
},
];
}
return {
getSyncTimestamp,
getActualDateRange,
getSelectOptions,
};
};
================================================
FILE: config/index.ts
================================================
import dayjs from 'dayjs';
/**
* 是否在开发环境
*/
export const isDev = process.env.NODE_ENV === 'development';
/**
* 网站标题
*/
export const websiteName = '公众号文章导出';
/**
* 文章列表每页大小,20为最大有效值
*/
export const ARTICLE_LIST_PAGE_SIZE = 20;
/**
* 公众号列表每页大小
*/
export const ACCOUNT_LIST_PAGE_SIZE = 5;
/**
* 公众号类型
*/
export const ACCOUNT_TYPE: Record<number, string> = {
0: '订阅号',
1: '订阅号',
2: '服务号',
};
/**
* Credentials 生存时间,单位:分钟
*/
export const CREDENTIAL_LIVE_MINUTES: number = 25;
/**
* Credentials 服务器主机地址
*/
export const CREDENTIAL_API_HOST = 'http://127.0.0.1:8088';
/**
* 文档站点地址
*/
export const docsWebSite = 'https://docs.mptext.top';
// 图片代理服务 todo: 这个可以在设置里增加一个配置项,网站是否启用图片代理,否的话置空即可。相应的,可以与 no-referer 配置互斥。
// export const IMAGE_PROXY = 'https://image.baidu.com/search/down?thumburl=';
export const IMAGE_PROXY = '';
/**
* 转发微信公众号请求时,使用的 user-agent 字符串
*/
export const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 WAE/1.0';
/**
* 微信公众号上线时间 2012-08-23
*/
export const MP_ORIGIN_TIMESTAMP = dayjs('2012-08-23 00:00:00').unix();
/**
* 文章显示类型
*/
export const ITEM_SHOW_TYPE: Record<number, string> = {
0: '普通图文',
5: '视频分享',
6: '音乐分享',
7: '音频分享',
8: '图片分享',
10: '文本分享',
11: '文章分享',
17: '短文',
};
/**
* 外部接口服务
*/
export const EXTERNAL_API_SERVICE = 'https://my-cron-service.deno.dev';
================================================
FILE: config/proxy.txt
================================================
https://00.workers-proxy.ggff.net
https://01.workers-proxy.ggff.net
https://02.workers-proxy.ggff.net
https://03.workers-proxy.ggff.net
https://04.workers-proxy.ggff.net
https://05.workers-proxy.ggff.net
https://06.workers-proxy.ggff.net
https://07.workers-proxy.ggff.net
https://08.workers-proxy.ggff.net
https://09.workers-proxy.ggff.net
https://10.workers-proxy.ggff.net
https://11.workers-proxy.ggff.net
https://12.workers-proxy.ggff.net
https://13.workers-proxy.ggff.net
https://14.workers-proxy.ggff.net
https://15.workers-proxy.ggff.net
================================================
FILE: config/public-apis.ts
================================================
// public api
export const apis = [
{
name: '根据关键字搜索公众号',
description: '根据公众号名称或关键字查询公众号列表。',
url: '/api/public/v1/account',
method: 'GET',
params: [
{
label: '关键字',
name: 'keyword',
location: 'query',
required: true,
default: 'N/A',
type: 'String',
remark: '',
},
{
label: '起始索引',
name: 'begin',
location: 'query',
required: false,
default: '0',
type: 'Int',
remark: '下标从0开始,不能为负',
},
{
label: '返回条数',
name: 'size',
location: 'query',
required: false,
default: '5',
type: 'Int',
remark: '最大不得超过20',
},
],
responseSample: {
base_resp: {
ret: 0,
err_msg: 'ok',
},
total: 2,
list: [
{
fakeid: 'MzA3NzAyMzMyMA==',
nickname: '铁路12306',
alias: 'CRTT12306',
round_head_img:
'http://mmbiz.qpic.cn/mmbiz_png/1774PicJv1ocOBxUD1Kh8gqx6HmD105nGnCg4j84mw4gxtmgsbgaVbiaOq6fCgVpHjwjELnMTGV8IR6kq7XJNkdw/0?wx_fmt=png',
service_type: 2,
signature: '欢迎您关注铁路12306,我们竭诚为您提供火车票、畅行会员、列车餐饮、酒店预订、旅游等服务。',
verify_status: 2,
},
{
fakeid: 'MjM5NTM0Mzg0MA==',
nickname: '12321受理中心',
alias: 'zg12321jbzx',
round_head_img:
'http://mmbiz.qpic.cn/mmbiz_png/b6geF0mDiaH4E82KiagmLc2DiaPrvzbSaELEde0xfZXrmcUzdBjK2G0ME4U1rwCvk0ZmtUsOncAkpnYTNoyDfhx6A/0?wx_fmt=png',
service_type: 2,
signature: ' ',
verify_status: 2,
},
],
},
},
{
name: '根据文章链接搜索公众号',
description: '根据公众号文章链接查询公众号。',
url: '/api/public/v1/accountbyurl',
method: 'GET',
params: [
{
label: '文章链接',
name: 'url',
location: 'query',
required: true,
default: 'N/A',
type: 'String',
remark: '',
},
],
responseSample: {
base_resp: {
ret: 0,
err_msg: 'ok',
},
list: [
{
fakeid: 'MzA3NTg4MDUzNQ==',
nickname: '肖小跑',
alias: '',
round_head_img:
'http://mmbiz.qpic.cn/mmbiz_png/TEq4bibSxYafowUFshRICokwNXiaUB9zCX3vicx8FuhTCGibTa478JI72bkbpa89ssAqEFm2ib1S1LB0FEjHycjib8OA/0?wx_fmt=png',
service_type: 1,
signature:
'金融世界不讲道理的时候,向文史哲求救,大概率“叮”的一下就扣上了。因为在这里,您才能再次看到“人”:人的情绪,人的荒诞,人的大举动小动作。这里有世界最本质的规律。',
verify_status: 0,
},
],
total: 1,
},
},
{
name: '获取文章列表',
description: '获取公众号的历史文章列表',
url: '/api/public/v1/article',
method: 'GET',
params: [
{
label: '公众号id',
name: 'fakeid',
location: 'query',
required: true,
default: 'N/A',
type: 'String',
remark: '',
},
{
label: '起始索引',
name: 'begin',
location: 'query',
required: false,
default: '0',
type: 'Int',
remark: '下标从0开始,不能为负',
},
{
label: '返回消息条数',
name: 'size',
location: 'query',
required: false,
default: '5',
type: 'Int',
remark: '最大不得超过20,一条消息可能会包含多篇文章',
},
],
responseSample: {
base_resp: {
err_msg: 'ok',
ret: 0,
},
articles: [
{
aid: '2247503214_1',
title: '我用ChatGPT AI Agent做了一个堆栈模拟器!',
cover:
'https://mmbiz.qpic.cn/mmbiz_jpg/jXQDbLkGBYXdb3LgmxYMRclBo2wibeyib4MFwhyI3mWQ6dwZOKvCXWibCXVLnr9e0rTUf9IzZn3LPDQBlEwXzyJ8Q/0?wx_fmt=jpeg',
link: 'https://mp.weixin.qq.com/s/wZxawrdSdSUAAZc89XuhWg',
digest: '',
update_time: 1753666492,
appmsgid: 2247503214,
itemidx: 1,
item_show_type: 0,
author_name: '轩辕之风',
tagid: [],
create_time: 1753666493,
is_pay_subscribe: 0,
has_red_packet_cover: 0,
album_id: '3457885223537541125',
checking: 0,
media_duration: '0:00',
mediaapi_publish_status: 0,
copyright_type: 1,
appmsg_album_infos: [
{
id: '3457885223537541125',
title: '人工智能',
album_id: 3457885223537541000,
appmsg_album_infos: [],
tagSource: 0,
},
],
pay_album_info: {
appmsg_album_infos: [],
},
is_deleted: false,
ban_flag: 0,
pic_cdn_url_235_1:
'https://mmbiz.qpic.cn/mmbiz_jpg/jXQDbLkGBYXdb3LgmxYMRclBo2wibeyib4V7nwzMbwLYKbgFSSbnQt0rUmdz1XcSE33YFzfqpVrYNKD8DagbnPRw/0?wx_fmt=jpeg',
pic_cdn_url_16_9: '',
pic_cdn_url_3_4: '',
pic_cdn_url_1_1:
'https://mmbiz.qpic.cn/mmbiz_jpg/jXQDbLkGBYXdb3LgmxYMRclBo2wibeyib4MFwhyI3mWQ6dwZOKvCXWibCXVLnr9e0rTUf9IzZn3LPDQBlEwXzyJ8Q/0?wx_fmt=jpeg',
cover_img:
'http://mmbiz.qpic.cn/mmbiz_jpg/jXQDbLkGBYXdb3LgmxYMRclBo2wibeyib4MFwhyI3mWQ6dwZOKvCXWibCXVLnr9e0rTUf9IzZn3LPDQBlEwXzyJ8Q/0?wx_fmt=jpeg',
cover_img_theme_color: {
r: 255,
g: 255,
b: 255,
},
line_info: {
use_line: 1,
line_count: 0,
is_appmsg_flag: 1,
is_use_flag: 0,
},
copyright_stat: 1,
is_rumor_refutation: 0,
multi_picture_cover: 0,
share_imageinfo: [],
},
{
aid: '2247503179_1',
title: '抖音C++安全开发面试题,已阵亡!',
cover:
'https://mmbiz.qpic.cn/mmbiz_jpg/jXQDbLkGBYUYIgV3fNdQyKIysEER6tQZCpgfTrYwfKX67eRWicg9TIciadwXJF9QMhUiaLaR4iabdwdiaib7FmWY62eg/0?wx_fmt=jpeg',
link: 'https://mp.weixin.qq.com/s/eFsIOWDXfs9DbcNmr2vtjQ',
digest: '',
update_time: 1753230604,
appmsgid: 2247503179,
itemidx: 1,
item_show_type: 0,
author_name: '轩辕之风',
tagid: [],
create_time: 1753230605,
is_pay_subscribe: 0,
has_red_packet_cover: 0,
album_id: '3560766261049098241',
checking: 0,
media_duration: '0:00',
mediaapi_publish_status: 0,
copyright_type: 1,
appmsg_album_infos: [
{
id: '3560766261049098241',
title: 'C/C++',
album_id: 3560766261049098000,
appmsg_album_infos: [],
tagSource: 0,
},
],
pay_album_info: {
appmsg_album_infos: [],
},
is_deleted: false,
ban_flag: 0,
pic_cdn_url_235_1:
'https://mmbiz.qpic.cn/mmbiz_jpg/jXQDbLkGBYUYIgV3fNdQyKIysEER6tQZuuUaqiazzPPoUFqFWicZ7bsW8z6g9FhkbibyqRtfZwRAkFHhPIydWpLnQ/0?wx_fmt=jpeg',
pic_cdn_url_16_9: '',
pic_cdn_url_3_4: '',
pic_cdn_url_1_1:
'https://mmbiz.qpic.cn/mmbiz_jpg/jXQDbLkGBYUYIgV3fNdQyKIysEER6tQZCpgfTrYwfKX67eRWicg9TIciadwXJF9QMhUiaLaR4iabdwdiaib7FmWY62eg/0?wx_fmt=jpeg',
line_info: {
use_line: 1,
line_count: 0,
is_appmsg_flag: 1,
is_use_flag: 0,
},
copyright_stat: 1,
is_rumor_refutation: 0,
multi_picture_cover: 0,
share_imageinfo: [],
},
],
},
},
{
name: '获取文章内容',
description: '获取文章内容,支持 html / markdown / text / json 格式',
url: '/api/public/v1/download',
method: 'GET',
params: [
{
label: '文章链接',
name: 'url',
location: 'query',
required: true,
default: 'N/A',
type: 'String',
remark: '需经过url编码',
},
{
label: '输出格式',
name: 'format',
location: 'query',
required: false,
default: 'html',
type: 'String',
remark: '支持 html / markdown / text / json 格式',
},
],
responseSample: {},
remark: '此接口不需要 API 密钥',
},
{
name: '查询公众号主体信息 (beta)',
description: '根据公众号的 fakeid 查询主体信息',
url: '/api/public/beta/authorinfo',
method: 'GET',
params: [
{
label: '公众号id',
name: 'fakeid',
location: 'query',
required: true,
default: 'N/A',
type: 'String',
remark: '',
},
],
responseSample: {
base_resp: {
exportkey_token: '',
ret: 0,
},
identity_name: '上海市总工会',
is_verify: 2,
original_article_count: 262,
},
remark: '此接口不需要 API 密钥',
},
{
name: '查询公众号主体信息 (beta)',
description: '根据公众号的 fakeid 查询主体信息',
url: '/api/public/beta/aboutbiz',
method: 'GET',
params: [
{
label: '公众号id',
name: 'fakeid',
location: 'query',
required: true,
default: 'N/A',
type: 'String',
remark: '',
},
{
label: '密钥',
name: 'key',
location: 'query',
required: false,
default: 'N/A',
type: 'String',
remark: '微信抓包获取的x-wechat-key参数',
},
],
responseSample: {
base_resp: {
ret: 0,
},
data: {
intro: '做一个有存在意义的账号~这里是上海市总工会,欢迎回家!',
wechat: 'shengongshewx',
type: '其他组织',
org: '上海市总工会',
auth_3rd_list: [
{
principal: '秀米',
userName: 'gh_d483129a0f29@app',
appId: 'wx19e904724550a41f',
relativeURL:
'pages/profile/index?enterpriseOpenId=sq_o0CFMs_h0aKah0GsYaZZ3akyF8yo&from=profile&fromAppId=wx63d70210fcc108fd&componentAppId=wx7d88eb47efa1e610',
category: [
{
id: 7,
name: '群发与通知',
desc: '基于该权限可帮助公众号进行群发消息、文章管理以及发送模板消息',
},
{
id: 11,
name: '素材管理',
desc: '基于该权限可帮助公众号管理图文等多媒体素材以及多媒体文件管理',
},
],
},
{
principal: '壹伴',
userName: 'gh_d483129a0f29@app',
appId: 'wx19e904724550a41f',
relativeURL:
'pages/profile/index?enterpriseOpenId=sq_omefmt3a0XFwUA1PfscJj7_n9fak&from=profile&fromAppId=wx63d70210fcc108fd&componentAppId=wx3f5f5ddf688562c0',
category: [
{
id: 1,
name: '消息管理',
desc: '基于该权限可帮助公众号接收用户消息,进行人工客服回复或自动回复',
},
{
id: 2,
name: '用户管理',
desc: '帮助公众号获取用户信息,进行用户管理',
},
{
id: 3,
name: '公众号账号信息服务',
desc: '基于该权限可帮助公众号设置及展示公众号信息、配置账号信息、生成带参二维码并配置跳转小程序等账号维度的功能',
},
{
id: 4,
name: '网页服务',
desc: '基于该权限可帮助公众实现H5网页服务',
},
{
id: 6,
name: '微信多客服',
desc: '基于该权限可帮助公众号使用微信多客服功能',
},
{
id: 7,
name: '群发与通知',
desc: '基于该权限可帮助公众号进行群发消息、文章管理以及发送模板消息',
},
{
id: 11,
name: '素材管理',
desc: '基于该权限可帮助公众号管理图文等多媒体素材以及多媒体文件管理',
},
{
id: 15,
name: '自定义菜单管理',
desc: '帮助公众号使用自定义菜单',
},
],
},
],
ip_wording: {
countryName: '中国',
countryId: '156',
provinceName: '上海',
provinceId: '',
cityName: '',
cityId: '',
},
},
},
remark: '此接口不需要 API 密钥',
},
];
================================================
FILE: config/public-proxy.ts
================================================
/**
* 公共代理节点
*/
export const PUBLIC_PROXY_LIST: string[] = [
...getDomainProxyList('worker-proxy.asia'),
...getDomainProxyList('net-proxy.asia'),
...getDomainProxyList('1235566.space'),
...getDomainProxyList('worker-proxy.shop'),
...getDomainProxyList('worker-proxys.cyou'),
...getDomainProxyList('worker-proxy.cyou'),
];
// 生成从00.到15.的16个二级域名
function getDomainProxyList(domain: string): string[] {
const list: string[] = [];
for (let i = 0; i < 16; i++) {
list.push(`https://${('0' + i).slice(-2)}.${domain}`);
}
return list;
}
================================================
FILE: config/shared-grid-options.ts
================================================
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
import { type GridOptions, themeQuartz } from 'ag-grid-community';
import GridLoading from '~/components/grid/Loading.vue';
import GridNoRows from '~/components/grid/NoRows.vue';
// 创建自定义的中文本地化,覆盖 columns 键
const customLocaleText = {
...AG_GRID_LOCALE_CN,
columns: '配置字段',
};
/**
* Grid表格公共配置
*/
export const sharedGridOptions: GridOptions = {
localeText: customLocaleText,
rowNumbers: {
resizable: true,
minWidth: 80,
maxWidth: 120,
},
loadingOverlayComponent: GridLoading,
noRowsOverlayComponent: GridNoRows,
sideBar: {
toolPanels: [
{
id: 'columns',
labelDefault: 'Columns',
labelKey: 'columns',
iconKey: 'columns',
toolPanel: 'agColumnsToolPanel',
minWidth: 225,
maxWidth: 225,
width: 225,
toolPanelParams: {
suppressRowGroups: true,
suppressValues: true,
suppressPivotMode: true,
},
},
],
position: 'right',
},
enableCellTextSelection: true,
tooltipShowDelay: 0,
tooltipShowMode: 'whenTruncated',
suppressContextMenu: true,
defaultColDef: {
sortable: true,
filter: true,
flex: 1,
enableCellChangeFlash: false,
suppressHeaderMenuButton: true,
suppressHeaderContextMenu: true,
enableValue: true,
enableRowGroup: true,
},
selectionColumnDef: {
sortable: true,
width: 80,
pinned: 'left',
},
rowSelection: {
mode: 'multiRow',
headerCheckbox: true,
selectAll: 'filtered',
},
theme: themeQuartz.withParams({
borderColor: '#e5e7eb',
rowBorder: true,
columnBorder: true,
headerFontWeight: 700,
oddRowBackgroundColor: '#00005506',
sidePanelBorder: true,
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", system-ui, sans-serif', // 优先使用常见中文字体
headerFontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif', // 表头也同步优化(可选)
fontSize: 14, // 默认通常是 13-14px,可适当增大到 15 或 16,让文字更舒展
// cellHorizontalPadding: 36, // 默认约 12px,增大可给单元格文字更多水平空间,缓解密集感
}),
};
================================================
FILE: error.vue
================================================
<template>
<NuxtLayout>
<div class="bg-[#f0f2f5] text-gray-900">
<div class="flex min-h-screen flex-col items-center justify-center text-center">
<div class="relative mb-8">
<svg
class="h-40 w-40 text-red-500"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
<path d="M2 17l10 5 10-5"></path>
<path d="M2 12l10 5 10-5"></path>
<line x1="1" x2="23" y1="1" y2="23"></line>
</svg>
</div>
<div class="relative">
<h1 class="glitch text-6xl font-bold text-[#0A0A0A] md:text-8xl" data-text="ERROR">ERROR</h1>
</div>
<h2 class="mt-4 text-3xl font-bold tracking-tight text-gray-800 sm:text-4xl">出了点小状况</h2>
<p class="mt-4 text-base text-gray-600">抱歉,页面出现了一些问题。请稍后重试。</p>
<div v-if="errorCode" class="mt-6 text-sm text-gray-500 opacity-75">
<p>
Error Code: <span class="font-mono">{{ errorCode }}</span>
</p>
</div>
<a
class="mt-8 inline-flex items-center justify-center rounded-lg bg-[#0A0A0A] px-5 py-3 text-base font-medium text-white shadow-md transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#0A0A0A] focus:ring-offset-2 focus:ring-offset-background-light"
href="/"
>
返回主页
</a>
</div>
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import { websiteName } from '~/config/index.js';
useHead({
title: `出了点小状况 | ${websiteName}`,
});
const route = useRoute();
const router = useRouter();
const errorCode = computed(() => route.query.error);
</script>
<style scoped>
.glitch {
position: relative;
animation: glitch 1s infinite;
}
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch::before {
left: 2px;
text-shadow: -2px 0 #ff00c1;
animation: glitch-anim 1s infinite;
clip: rect(44px, 450px, 56px, 0);
}
.glitch::after {
left: -2px;
text-shadow:
-2px 0 #00fff9,
2px 2px #ff00c1;
animation: glitch-anim-2 1s infinite;
clip: rect(85px, 450px, 90px, 0);
}
@keyframes glitch {
0% {
transform: translate(0);
}
20% {
transform: translate(-3px, 3px);
}
40% {
transform: translate(-3px, -3px);
}
60% {
transform: translate(3px, 3px);
}
80% {
transform: translate(3px, -3px);
}
100% {
transform: translate(0);
}
}
@keyframes glitch-anim {
0% {
clip: rect(32px, 9999px, 89px, 0);
}
5% {
clip: rect(51px, 9999px, 83px, 0);
}
10% {
clip: rect(47px, 9999px, 12px, 0);
}
15% {
clip: rect(7px, 9999px, 63px, 0);
}
20% {
clip: rect(41px, 9999px, 54px, 0);
}
25% {
clip: rect(15px, 9999px, 100px, 0);
}
30% {
clip: rect(82px, 9999px, 3px, 0);
}
35% {
clip: rect(10px, 9999px, 5px, 0);
}
40% {
clip: rect(88px, 9999px, 73px, 0);
}
45% {
clip: rect(78px, 9999px, 19px, 0);
}
50% {
clip: rect(46px, 9999px, 20px, 0);
}
55% {
clip: rect(8px, 9999px, 69px, 0);
}
60% {
clip: rect(89px, 9999px, 45px, 0);
}
65% {
clip: rect(57px, 9999px, 59px, 0);
}
70% {
clip: rect(25px, 9999px, 98px, 0);
}
75% {
clip: rect(13px, 9999px, 74px, 0);
}
80% {
clip: rect(34px, 9999px, 26px, 0);
}
85% {
clip: rect(98px, 9999px, 44px, 0);
}
90% {
clip: rect(1px, 9999px, 35px, 0);
}
95% {
clip: rect(62px, 9999px, 9px, 0);
}
100% {
clip: rect(60px, 9999px, 4px, 0);
}
}
@keyframes glitch-anim-2 {
0% {
clip: rect(61px, 9999px, 78px, 0);
}
5% {
clip: rect(6px, 9999px, 55px, 0);
}
10% {
clip: rect(10px, 9999px, 7px, 0);
}
15% {
clip: rect(68px, 9999px, 76px, 0);
}
20% {
clip: rect(20px, 9999px, 24px, 0);
}
25% {
clip: rect(90px, 9999px, 21px, 0);
}
30% {
clip: rect(78px, 9999px, 39px, 0);
}
35% {
clip: rect(2px, 9999px, 57px, 0);
}
40% {
clip: rect(59px, 9999px, 30px, 0);
}
45% {
clip: rect(11px, 9999px, 60px, 0);
}
50% {
clip: rect(3px, 9999px, 62px, 0);
}
55% {
clip: rect(4px, 9999px, 86px, 0);
}
60% {
clip: rect(56px, 9999px, 95px, 0);
}
65% {
clip: rect(40px, 9999px, 6px, 0);
}
70% {
clip: rect(80px, 9999px, 70px, 0);
}
75% {
clip: rect(81px, 9999px, 47px, 0);
}
80% {
clip: rect(93px, 9999px, 36px, 0);
}
85% {
clip: rect(31px, 9999px, 99px, 0);
}
90% {
clip: rect(87px, 9999px, 22px, 0);
}
95% {
clip: rect(100px, 9999px, 52px, 0);
}
100% {
clip: rect(12px, 9999px, 23px, 0);
}
}
.glitch::before,
.glitch::after {
background: #f0f2f5;
}
</style>
================================================
FILE: nuxt.config.ts
================================================
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-10-30',
devtools: {
enabled: false,
},
modules: ['@vueuse/nuxt', '@nuxt/ui', 'nuxt-monaco-editor', '@sentry/nuxt/module', 'nuxt-umami'],
ssr: false,
runtimeConfig: {
public: {
aggridLicense: process.env.NUXT_AGGRID_LICENSE,
sentry: {
dsn: process.env.NUXT_SENTRY_DSN,
},
},
debugMpRequest: false,
},
app: {
head: {
meta: [
{
name: 'referrer',
content: 'no-referrer',
},
],
script: [
{
src: '/vendors/html-docx-js@0.3.1/html-docx.js',
defer: true,
},
],
},
},
sourcemap: {
client: 'hidden',
},
nitro: {
minify: process.env.NODE_ENV === 'production',
storage: {
kv: {
driver: process.env.NITRO_KV_DRIVER || 'memory',
base: process.env.NITRO_KV_BASE,
},
},
},
monacoEditor: {
locale: 'en',
componentName: {
codeEditor: 'MonacoEditor', // 普通编辑器组件名
diffEditor: 'MonacoDiffEditor', // 差异编辑器组件名
},
},
// https://docs.sentry.io/platforms/javascript/guides/nuxt/manual-setup/
sentry: {
org: process.env.NUXT_SENTRY_ORG,
project: process.env.NUXT_SENTRY_PROJECT,
authToken: process.env.NUXT_SENTRY_AUTH_TOKEN,
telemetry: false,
},
// https://umami.nuxt.dev/api/configuration
umami: {
enabled: true,
id: process.env.NUXT_UMAMI_ID,
host: process.env.NUXT_UMAMI_HOST,
domains: ['down.mptext.top'],
ignoreLocalhost: true,
autoTrack: true,
logErrors: true,
},
});
================================================
FILE: package.json
================================================
{
"name": "wechat-article-exporter",
"version": "2.3.12",
"type": "module",
"scripts": {
"debug": "nuxt dev --inspect",
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "NITRO_PRESET=cloudflare_pages nuxt build && npx wrangler --cwd dist pages dev",
"format": "biome check --write",
"postinstall": "nuxt prepare",
"docker:build": "docker build --build-arg VERSION=$npm_package_version -t ghcr.io/wechat-article/wechat-article-exporter:$npm_package_version .",
"docker:publish": "docker push ghcr.io/wechat-article/wechat-article-exporter:$npm_package_version"
},
"dependencies": {
"@ag-grid-community/locale": "^34.0.0",
"@iconify-json/famicons": "^1.2.0",
"@iconify-json/heroicons": "^1.2.2",
"@iconify-json/heroicons-solid": "^1.2.1",
"@nuxt/icon": "^1.3.1",
"@nuxt/ui": "^2.17.0",
"@nuxtjs/tailwindcss": "^6.12.0",
"@sentry/nuxt": "^10.27.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.11.0",
"@vueuse/nuxt": "^10.11.0",
"ag-grid-enterprise": "^34.0.0",
"ag-grid-vue3": "^34.0.0",
"cheerio": "^1.1.2",
"date-fns": "^4.1.0",
"dayjs": "^1.11.12",
"defu": "^6.1.4",
"dexie": "^4.0.11",
"dompurify": "^3.2.5",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"happy-dom": "^20.1.0",
"highlight.js": "^11.11.1",
"jszip": "^3.10.1",
"lucide-vue-next": "^0.441.0",
"mime": "^4.0.4",
"monaco-editor": "^0.54.0",
"nuxt": "^3.12.3",
"nuxt-monaco-editor": "1.4.0",
"nuxt-umami": "3.2.1",
"p-queue": "^8.0.1",
"turndown": "^7.2.0",
"uuid": "^11.0.3",
"v-calendar": "^3.1.2",
"vue": "^3.5.22",
"vue-shadow-dom": "^4.2.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "2.3.8",
"@types/file-saver": "^2.0.7",
"@types/turndown": "^5.0.5",
"prettier": "^3.5.2",
"tailwindcss-debug-screens": "^2.2.1",
"typescript": "^5.5.3"
},
"engines": {
"node": ">=22"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}
================================================
FILE: pages/dashboard/account.vue
================================================
<script setup lang="ts">
import type {
ColDef,
GetRowIdParams,
GridApi,
GridOptions,
GridReadyEvent,
ICellRendererParams,
SelectionChangedEvent,
ValueGetterParams,
} from 'ag-grid-community';
import { AgGridVue } from 'ag-grid-vue3';
import { defu } from 'defu';
import { formatTimeStamp } from '#shared/utils/helpers';
import { getArticleList } from '~/apis';
import GlobalSearchAccountDialog from '~/components/global/SearchAccountDialog.vue';
import GridAccountActions from '~/components/grid/AccountActions.vue';
import GridLoadProgress from '~/components/grid/LoadProgress.vue';
import ConfirmModal from '~/components/modal/Confirm.vue';
import LoginModal from '~/components/modal/Login.vue';
import toastFactory from '~/composables/toast';
import useLoginCheck from '~/composables/useLoginCheck';
import { IMAGE_PROXY, websiteName } from '~/config';
import { sharedGridOptions } from '~/config/shared-grid-options';
import { deleteAccountData } from '~/store/v2';
import { getArticleCache, hitCache } from '~/store/v2/article';
import { getAllInfo, getInfoCache, importMpAccounts, type MpAccount } from '~/store/v2/info';
import type { AccountManifest } from '~/types/account';
import type { Preferences } from '~/types/preferences';
import { exportAccountJsonFile } from '~/utils/exporter';
import { createBooleanColumnFilterParams, createDateColumnFilterParams } from '~/utils/grid';
useHead({
title: `公众号管理 | ${websiteName}`,
});
interface PromiseInstance {
resolve: (value: unknown) => void;
reject: (reason?: any) => void;
}
const toast = toastFactory();
const modal = useModal();
const { checkLogin } = useLoginCheck();
const { getSyncTimestamp } = useSyncDeadline();
const syncToTimestamp = getSyncTimestamp();
const preferences = usePreferences();
// 账号事件总线,用于和 Credentials 面板保持列表同步
const { accountEventBus } = useAccountEventBus();
accountEventBus.on(event => {
if (event === 'account-added' || event === 'account-removed') {
refresh();
}
});
const searchAccountDialogRef = ref<typeof GlobalSearchAccountDialog | null>(null);
const addBtnLoading = ref(false);
function addAccount() {
if (!checkLogin()) return;
searchAccountDialogRef.value!.open();
}
async function onSelectAccount(account: MpAccount) {
addBtnLoading.value = true;
await loadAccountArticle(account, false);
await refresh();
addBtnLoading.value = false;
toast.success('公众号添加成功', `已成功添加公众号【${account.nickname}】,并同步了第一页的文章数据`);
// 通知 Credentials 面板按钮立即变更为“已添加”
accountEventBus.emit('account-added', { fakeid: account.fakeid });
}
// 表示同步过程中是否执行了取消操作
const isCanceled = ref(false);
const isDeleting = ref(false);
const isSyncing = ref(false);
// 当前正在同步的公众号id
const syncingRowId = ref<string | null>(null);
const syncTimer = ref<number | null>(null);
async function _load(account: MpAccount, begin: number, loadMore: boolean, promise: PromiseInstance) {
if (isCanceled.value) {
isCanceled.value = false; // 这里需要将状态复位
promise.reject(new Error('已取消同步'));
return;
}
syncingRowId.value = account.fakeid;
isSyncing.value = true;
const [articles, completed] = await getArticleList(account, begin);
if (isCanceled.value) {
isCanceled.value = false;
promise.reject(new Error('已取消同步'));
return;
}
if (completed) {
await updateRow(account.fakeid);
syncingRowId.value = null;
isSyncing.value = false;
promise.resolve(account);
return;
}
const count = articles.filter(article => article.itemidx === 1).length; // 消息数
begin += count;
// 检查是否可以「快进」,也就是存在比 lastArticle 更早的缓存数据
// todo: 这里还可以继续优化,防止出现多段不连续的范围
const lastArticle = articles.at(-1);
if (lastArticle && lastArticle.create_time < account.last_update_time!) {
if (await hitCache(account.fakeid, lastArticle.create_time)) {
const cachedArticles = await getArticleCache(account.fakeid, lastArticle.create_time);
// 更新 begin 参数
const count = cachedArticles.filter(article => article.itemidx === 1).length;
begin += count;
articles.push(...cachedArticles);
}
}
if (articles.at(-1)!.create_time < syncToTimestamp) {
// 已同步到配置的时间范围
loadMore = false;
}
await updateRow(account.fakeid);
if (loadMore) {
syncTimer.value = window.setTimeout(
() => {
if (isCanceled.value) {
console.warn('已取消同步');
isCanceled.value = false;
promise.reject(new Error('已取消同步'));
return;
}
_load(account, begin, true, promise);
},
((preferences.value as unknown as Preferences).accountSyncSeconds || 5) * 1000
);
} else {
syncingRowId.value = null;
isSyncing.value = false;
promise.resolve(account);
}
}
// 同步指定公众号
async function loadAccountArticle(account: MpAccount, loadMore = true) {
return new Promise((resolve, reject) => {
const promise: PromiseInstance = { resolve, reject };
_load(account, 0, loadMore, promise).catch(e => {
syncingRowId.value = null;
isSyncing.value = false;
if (e.message === 'session expired') {
modal.open(LoginModal);
}
reject(e);
});
});
}
// 同步所有公众号
async function loadSelectedAccountArticle() {
if (!checkLogin()) return;
isCanceled.value = false;
try {
const rows = getSelectedRows();
for (const account of rows) {
await loadAccountArticle(account);
}
toast.success(`已成功同步 ${rows.length} 个公众号`);
} catch (e: any) {
toast.error('同步失败', e.message);
}
}
let globalRowData: MpAccount[] = [];
const columnDefs = ref<ColDef[]>([
{
colId: 'fakeid',
headerName: 'fakeid',
field: 'fakeid',
cellDataType: 'text',
filter: 'agTextColumnFilter',
minWidth: 200,
cellClass: 'font-mono',
initialHide: true,
},
{
colId: 'round_head_img',
headerName: '头像',
field: 'round_head_img',
sortable: false,
filter: false,
cellRenderer: (params: ICellRendererParams) => {
return `<img alt="" src="${IMAGE_PROXY + params.value}" style="height: 30px; width: 30px; object-fit: cover; border: 1px solid #e5e7eb; border-radius: 100%;" />`;
},
cellClass: 'flex justify-center items-center',
minWidth: 80,
},
{
colId: 'nickname',
headerName: '名称',
field: 'nickname',
cellDataType: 'text',
filter: 'agTextColumnFilter',
tooltipField: 'nickname',
minWidth: 200,
},
{
colId: 'create_time',
headerName: '添加时间',
field: 'create_time',
valueFormatter: p => (p.value ? formatTimeStamp(p.value) : ''),
filter: 'agDateColumnFilter',
filterParams: createDateColumnFilterParams(),
filterValueGetter: (params: ValueGetterParams) => {
return new Date(params.getValue('create_time') * 1000);
},
sort: 'desc',
minWidth: 180,
initialHide: true,
cellClass: 'flex justify-center items-center font-mono',
},
{
colId: 'update_time',
headerName: '最后同步时间',
field: 'update_time',
valueFormatter: p => (p.value ? formatTimeStamp(p.value) : ''),
filter: 'agDateColumnFilter',
filterParams: createDateColumnFilterParams(),
filterValueGetter: (params: ValueGetterParams) => {
return new Date(params.getValue('update_time') * 1000);
},
minWidth: 180,
cellClass: 'flex justify-center items-center font-mono',
},
{
colId: 'total_count',
headerName: '消息总数',
field: 'total_count',
cellDataType: 'number',
cellRenderer: 'agAnimateShowChangeCellRenderer',
filter: 'agNumberColumnFilter',
cellClass: 'flex justify-center items-center font-mono',
minWidth: 150,
},
{
colId: 'count',
headerName: '已同步消息数',
field: 'count',
cellDataType: 'number',
cellRenderer: 'agAnimateShowChangeCellRenderer',
filter: 'agNumberColumnFilter',
cellClass: 'flex justify-center items-center font-mono',
minWidth: 180,
},
{
colId: 'articles',
headerName: '已同步文章数',
field: 'articles',
cellDataType: 'number',
cellRenderer: 'agAnimateShowChangeCellRenderer',
filter: 'agNumberColumnFilter',
cellClass: 'flex justify-center items-center font-mono',
minWidth: 180,
initialHide: true,
},
{
colId: 'load_percent',
headerName: '同步进度',
valueGetter: params => (params.data.total_count === 0 ? 0 : params.data.count / params.data.total_count),
cellDataType: 'number',
cellRenderer: GridLoadProgress,
filter: 'agNumberColumnFilter',
minWidth: 200,
},
{
colId: 'completed',
headerName: '是否同步完成',
field: 'completed',
cellDataType: 'boolean',
filter: 'agSetColumnFilter',
filterParams: createBooleanColumnFilterParams('已同步完成', '未同步完成'),
cellClass: 'flex justify-center items-center',
headerClass: 'justify-center',
minWidth: 200,
},
{
colId: 'action',
headerName: '操作',
field: 'fakeid',
sortable: false,
filter: false,
cellRenderer: GridAccountActions,
cellRendererParams: {
onSync: (params: ICellRendererParams) => {
if (!checkLogin()) return;
isCanceled.value = false;
loadAccountArticle(params.data)
.then(() => {
toast.success('同步完成', `公众号【${params.data.nickname}】的文章已同步完毕`);
})
.catch(e => {
toast.error('同步失败', e.message);
});
},
onStop: (params: ICellRendererParams) => {
isCanceled.value = true;
if (syncTimer.value) {
window.clearTimeout(syncTimer.value);
syncTimer.value = null;
}
syncingRowId.value = null;
isSyncing.value = false;
},
isDeleting: isDeleting,
isSyncing: isSyncing,
syncingRowId: syncingRowId,
},
cellClass: 'flex justify-center items-center',
maxWidth: 100,
pinned: 'right',
},
]);
// 注意,`defu`函数最左边的参数优先级最高
const gridOptions: GridOptions = defu(
{
getRowId: (params: GetRowIdParams) => String(params.data.fakeid),
},
sharedGridOptions
);
const gridApi = shallowRef<GridApi | null>(null);
function onGridReady(params: GridReadyEvent) {
gridApi.value = params.api;
restoreColumnState();
refresh();
}
function onColumnStateChange() {
if (gridApi.value) {
saveColumnState();
}
}
function saveColumnState() {
const state = gridApi.value?.getColumnState();
localStorage.setItem('agGridColumnState-account', JSON.stringify(state));
}
function restoreColumnState() {
const stateStr = localStorage.getItem('agGridColumnState-account');
if (stateStr) {
const state = JSON.parse(stateStr);
gridApi.value?.applyColumnState({
state,
applyOrder: true,
});
}
}
async function refresh() {
globalRowData = await getAllInfo();
gridApi.value?.setGridOption('rowData', globalRowData);
}
async function updateRow(fakeid: string) {
const rowNode = gridApi.value?.getRowNode(fakeid);
if (rowNode) {
const info = await getInfoCache(fakeid);
rowNode.updateData(info);
}
}
// 当前是否有选中的行
const hasSelectedRows = ref(false);
function onSelectionChanged(evt: SelectionChangedEvent) {
hasSelectedRows.value = (evt.selectedNodes?.map(node => node.data) || []).length > 0;
}
function getSelectedRows() {
const rows: MpAccount[] = [];
gridApi.value?.forEachNodeAfterFilterAndSort(node => {
if (node.isSelected()) {
rows.push(node.data);
}
});
return rows;
}
// 删除所选的公众号数据
function deleteSelectedAccounts() {
const rows = getSelectedRows();
const ids = rows.map(info => info.fakeid);
modal.open(ConfirmModal, {
title: '确定要删除所选公众号的数据吗?',
description: '删除之后,该公众号的所有数据(包括已下载的文章和留言等)都将被清空。',
async onConfirm() {
try {
isDeleting.value = true;
await deleteAccountData(ids);
// 通知 Credentials 面板这些公众号已被移除
ids.forEach(fakeid => accountEventBus.emit('account-removed', { fakeid: fakeid }));
} finally {
isDeleting.value = false;
await refresh();
}
},
});
}
// 导入公众号
const fileRef = ref<HTMLInputElement | null>(null);
const importBtnLoading = ref(false);
function importAccount() {
fileRef.value!.click();
}
async function handleFileChange(evt: Event) {
const files = (evt.target as HTMLInputElement).files;
if (files && files.length > 0) {
const file = files[0];
try {
importBtnLoading.value = true;
// 解析 JSON
const jsonData = JSON.parse(await file.text());
if (jsonData.usefor !== 'wechat-article-exporter') {
// 文件格式不正确
toast.error('导入公众号失败', '导入文件格式不正确,请选择该网站导出的文件进行导入。');
return;
}
const infos = jsonData.accounts;
if (!infos || infos.length <= 0) {
// 文件格式不正确
toast.error('导入公众号失败', '导入文件格式不正确,请选择该网站导出的文件进行导入。');
return;
}
await importMpAccounts(infos);
await refresh();
} catch (error) {
console.error('导入公众号时 JSON 解析失败:', error);
toast.error('导入公众号', (error as Error).message);
} finally {
importBtnLoading.value = false;
}
}
}
// 导出公众号
const exportBtnLoading = ref(false);
function exportAccount() {
exportBtnLoading.value = true;
try {
const rows = getSelectedRows();
const data: AccountManifest = {
version: '1.0',
usefor: 'wechat-article-exporter',
accounts: rows,
};
exportAccountJsonFile(data, '公众号');
toast.success('导出公众号', `成功导出了 ${rows.length} 个公众号`);
} finally {
exportBtnLoading.value = false;
}
}
const { getActualDateRange } = useSyncDeadline();
</script>
<template>
<div class="h-full">
<Teleport defer to="#title">
<h1 class="text-[28px] leading-[34px] text-slate-12 dark:text-slate-50 font-bold">公众号管理</h1>
</Teleport>
<div class="flex flex-col h-full divide-y divide-gray-200">
<!-- 顶部操作区 -->
<header class="flex items-stretch gap-3 px-3 py-3">
<UButton icon="i-lucide:user-plus" color="blue" :disabled="isDeleting || addBtnLoading" @click="addAccount">
{{ addBtnLoading ? '添加中...' : '添加' }}
</UButton>
<UButton icon="i-lucide:arrow-down-to-line" color="blue" :loading="importBtnLoading" @click="importAccount">
批量导入
<input ref="fileRef" type="file" accept=".json" class="hidden" @change="handleFileChange" />
</UButton>
<UButton
icon="i-lucide:arrow-up-from-line"
color="blue"
:loading="exportBtnLoading"
:disabled="!hasSelectedRows"
@click="exportAccount"
>
批量导出
</UButton>
<UButton
color="rose"
icon="i-lucide:user-minus"
class="disabled:opacity-35"
:loading="isDeleting"
:disabled="!hasSelectedRows"
@click="deleteSelectedAccounts"
>删除</UButton
>
<UButton
color="black"
icon="i-heroicons:arrow-path-rounded-square-20-solid"
class="disabled:opacity-35"
:loading="isSyncing"
:disabled="isDeleting || !hasSelectedRows"
@click="loadSelectedAccountArticle"
>同步</UButton
>
<div class="hidden xl:flex flex-1 justify-end">
<span class="self-end text-sm text-blue-500 font-medium">同步范围: {{ getActualDateRange() }}</span>
</div>
</header>
<!-- 数据表格 -->
<ag-grid-vue
style="width: 100%; height: 100%"
:rowData="globalRowData"
:columnDefs="columnDefs"
:gridOptions="gridOptions"
@grid-ready="onGridReady"
@selection-changed="onSelectionChanged"
@column-moved="onColumnStateChange"
@column-visible="onColumnStateChange"
@column-pinned="onColumnStateChange"
@column-resized="onColumnStateChange"
></ag-grid-vue>
</div>
<!-- 添加公众号弹框 -->
<GlobalSearchAccountDialog ref="searchAccountDialogRef" @select:account="onSelectAccount" />
</div>
</template>
================================================
FILE: pages/dashboard/album.vue
================================================
<template>
<div class="h-full">
<Teleport defer to="#title">
<h1 class="text-[28px] leading-[34px] text-slate-12 dark:text-slate-50 font-bold">合集下载</h1>
</Teleport>
<div class="flex flex-col h-full divide-y divide-gray-200">
<!-- 顶部筛选与操作区 -->
<header class="flex flex-col items-start xl:flex-row xl:items-center gap-2 xl:justify-between px-3 py-2">
<div class="flex gap-2">
<div class="flex items-center space-x-3">
<AccountSelectorForAlbum v-model="selectedAccount" class="w-80" />
<USelectMenu
class="w-60"
color="gray"
v-model="selectedAlbum"
:options="selectedAccount?.albums || []"
option-attribute="title"
size="md"
placeholder="选择合集"
/>
<div>
<Loader v-if="switchSortLoading" :size="24" class="animate-spin text-slate-500" />
<div v-else class="flex space-x-2 w-fit" @click="toggleReverse">
<ArrowUpNarrowWide v-if="isReverse" />
<ArrowDownNarrowWide v-else />
<span>{{ isReverse ? '正序' : '倒序' }}</span>
</div>
</div>
<UButton
color="black"
variant="solid"
class="disabled:bg-slate-4 disabled:text-slate-12"
:loading="fetchAllArticlesBtnLoading"
:disabled="!selectedAccount || !selectedAlbum || albumArticles.length === 0 || noMoreData"
@click="fetchAllArticles"
>抓取全部文章链接</UButton
>
</div>
</div>
<div class="flex items-center space-x-2">
<UButton
color="blue"
variant="link"
size="md"
:disabled="!selectedAccount || !selectedAlbum"
@click="gotoLink(originalAlbumURL)"
>跳转到原始链接</UButton
>
<UButton
color="black"
variant="solid"
size="md"
class="disabled:bg-slate-4 disabled:text-slate-12"
:disabled="!selectedAccount || !selectedAlbum || albumArticles.length === 0 || batchDownloadLoading"
@click="doBatchDownload"
>
<Loader v-if="batchDownloadLoading" :size="20" class="animate-spin" />
<span v-if="batchDownloadLoading"
>{{ batchDownloadPhase }}:
<span v-if="batchDownloadPhase === '下载文章内容'"
>{{ batchDownloadedCount }}/{{ selectedArticleCount }}</span
>
<span v-if="batchDownloadPhase === '打包'">{{ batchPackedCount }}/{{ batchDownloadedCount }}</span>
</span>
<span v-else>批量下载</span>
</UButton>
</div>
</header>
<!-- 合集文章列表 -->
<main class="flex-1 overflow-y-scroll bg-[#ededed]" v-if="selectedAccount && selectedAlbum">
<div v-if="albumLoading" class="flex justify-center items-center mt-5">
<Loader :size="28" class="animate-spin text-slate-500" />
</div>
<div v-else-if="albumBaseInfo" class="relative max-w-2xl mx-auto bg-white">
<!-- banner -->
<div class="px-5 py-7 banner">
<h2 class="text-2xl text-white font-bold"># {{ albumBaseInfo.title }}</h2>
</div>
<div class="sticky top-0 px-5 py-3 bg-white border-b">
<p class="flex items-center space-x-2 mb-2">
<img class="size-5" :src="albumBaseInfo.brand_icon" alt="" />
<span>{{ albumBaseInfo.nickname }}</span>
</p>
<p class="text-sm text-slate-10">
<span>{{ albumBaseInfo.article_count }}篇内容</span>
<span v-if="albumBaseInfo.description"> · {{ albumBaseInfo.description }}</span>
</p>
</div>
<div class="bg-white px-4 pb-6">
<!-- 文章列表 -->
<ul class="divide-y">
<li
class="flex justify-between items-center py-5 px-1"
v-for="article in albumArticles"
:key="article.key"
>
<div class="flex-1">
<h3 class="text-lg mb-2">
<span v-if="article.pos_num">{{ article.pos_num }}. </span>
<span>{{ article.title }}</span>
</h3>
<time class="text-sm text-slate-10">{{ formatAlbumTime(+article.create_time) }}</time>
</div>
<img class="size-16 ml-4 flex-shrink-0" :src="article.cover_img_1_1" alt="" />
</li>
</ul>
<!-- 底部加载条 -->
<div v-element-visibility="onElementVisibility"></div>
<p v-if="articleLoading" class="flex justify-center items-center mt-2 py-2">
<Loader :size="28" class="animate-spin text-slate-500" />
</p>
<p v-else-if="noMoreData" class="text-center mt-2 py-2 text-slate-400">已全部加载完毕</p>
</div>
</div>
</main>
</div>
</div>
gitextract_6nyqcz44/
├── .dockerignore
├── .env.example
├── .github/
│ └── workflows/
│ └── docker.yml
├── .gitignore
├── .prettierrc
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── apis/
│ └── index.ts
├── app.vue
├── biome.json
├── components/
│ ├── ButtonGroup.vue
│ ├── Placeholder.vue
│ ├── ProxyMetrics.vue
│ ├── RadioGroup.vue
│ ├── StorageUsage.vue
│ ├── api/
│ │ ├── CodeSegment.vue
│ │ ├── DebugModal.vue
│ │ ├── Document.vue
│ │ └── Summary.vue
│ ├── base/
│ │ ├── DatePicker.vue
│ │ ├── ExternalLink.vue
│ │ └── Tag.vue
│ ├── dashboard/
│ │ ├── Actions.vue
│ │ ├── AuthPopoverPanel.vue
│ │ ├── BottomPanel.vue
│ │ ├── NavMenus.vue
│ │ └── SideBar.vue
│ ├── global/
│ │ ├── CredentialsDialog.vue
│ │ └── SearchAccountDialog.vue
│ ├── grid/
│ │ ├── AccountActions.vue
│ │ ├── Album.vue
│ │ ├── ArticleActions.vue
│ │ ├── BooleanCellRenderer.vue
│ │ ├── CoverTooltip.vue
│ │ ├── LoadProgress.vue
│ │ ├── Loading.vue
│ │ ├── NoRows.vue
│ │ └── StatusBar.vue
│ ├── modal/
│ │ ├── Confirm.vue
│ │ ├── Login.vue
│ │ └── QQGroup.vue
│ ├── preview/
│ │ ├── Article.vue
│ │ └── HtmlRenderer.vue
│ ├── search/
│ │ ├── AccountForm.vue
│ │ └── Article.vue
│ ├── selector/
│ │ ├── AccountSelectorForAlbum.vue
│ │ └── AccountSelectorForArticle.vue
│ └── setting/
│ ├── Display.vue
│ ├── Export.vue
│ ├── Misc.vue
│ └── Proxy.vue
├── composables/
│ ├── toast.ts
│ ├── useAccountEventBus.ts
│ ├── useBatchDownload.ts
│ ├── useDownloader.ts
│ ├── useExporter.ts
│ ├── useLoginAccount.ts
│ ├── useLoginCheck.ts
│ ├── usePreferences.ts
│ └── useSyncDeadline.ts
├── config/
│ ├── index.ts
│ ├── proxy.txt
│ ├── public-apis.ts
│ ├── public-proxy.ts
│ └── shared-grid-options.ts
├── error.vue
├── nuxt.config.ts
├── package.json
├── pages/
│ ├── dashboard/
│ │ ├── account.vue
│ │ ├── album.vue
│ │ ├── api.vue
│ │ ├── article.vue
│ │ ├── proxy.vue
│ │ ├── settings.vue
│ │ ├── single.vue
│ │ └── support.vue
│ ├── dashboard.vue
│ ├── dev/
│ │ ├── discuss.vue
│ │ ├── example-error.vue
│ │ ├── markdown.vue
│ │ └── youtube-channel-id.vue
│ └── index.vue
├── public/
│ ├── custom-elements/
│ │ └── mp-common-mpaudio.js
│ ├── plugins/
│ │ └── credential.py
│ └── vendors/
│ └── html-docx-js@0.3.1/
│ └── html-docx.js
├── samples/
│ ├── aboutbiz/
│ │ ├── biz-MjM5ODMxNzE2NQ==.html
│ │ ├── biz-MjM5OTM0OTE1Mg==.html
│ │ ├── biz-MzAxODU1ODg2Mg==.html
│ │ ├── biz-MzI0Nzg2MTExMA==.html
│ │ ├── biz-MzI4NjAxNjY4Nw==.html
│ │ ├── biz-MzIxMTgzNjE5MA==.html
│ │ └── biz-Mzg3OTYzMDkzMg==.html
│ ├── author/
│ │ ├── 01.html
│ │ └── 包含author信息.md
│ ├── 作者已删除/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── 05.html
│ │ ├── 06.html
│ │ └── 作者已删除.md
│ ├── 内容违规/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ └── 内容违规.md
│ ├── 图片分享/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── 05.html
│ │ └── 图片分享.md
│ ├── 文本分享/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── c01.html
│ │ ├── c02.html
│ │ ├── c03.html
│ │ ├── c04.html
│ │ ├── c05.html
│ │ └── 文本分享.md
│ ├── 文章分享/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ └── 文章分享.md
│ ├── 普通图文/
│ │ ├── 01.html
│ │ ├── 02.html
│ │ ├── 03.html
│ │ ├── 04.html
│ │ ├── c01.html
│ │ └── 普通图文.md
│ └── 该内容暂时无法查看/
│ ├── 01.html
│ └── 无法查看.md
├── sentry.client.config.ts
├── server/
│ ├── api/
│ │ ├── _debug.get.ts
│ │ ├── public/
│ │ │ ├── beta/
│ │ │ │ ├── aboutbiz.get.ts
│ │ │ │ └── authorinfo.get.ts
│ │ │ └── v1/
│ │ │ ├── account.get.ts
│ │ │ ├── accountbyurl.get.ts
│ │ │ ├── article.get.ts
│ │ │ ├── authkey.get.ts
│ │ │ └── download.get.ts
│ │ └── web/
│ │ ├── login/
│ │ │ ├── bizlogin.post.ts
│ │ │ ├── getqrcode.get.ts
│ │ │ ├── scan.get.ts
│ │ │ └── session/
│ │ │ └── [sid].post.ts
│ │ ├── misc/
│ │ │ ├── accountname.get.ts
│ │ │ ├── appmsgalbum.get.ts
│ │ │ ├── comment.get.ts
│ │ │ └── current-ip.get.ts
│ │ ├── mp/
│ │ │ ├── appmsgpublish.get.ts
│ │ │ ├── info.get.ts
│ │ │ ├── logout.get.ts
│ │ │ ├── profile_ext_getmsg.get.ts
│ │ │ ├── searchbiz.get.ts
│ │ │ └── searchbyurl.get.ts
│ │ └── worker/
│ │ ├── README.md
│ │ ├── blocked-ip-list.get.ts
│ │ ├── overview-metrics.get.ts
│ │ └── security-top-n.get.ts
│ ├── kv/
│ │ └── cookie.ts
│ ├── tsconfig.json
│ ├── types.d.ts
│ └── utils/
│ ├── CookieStore.ts
│ ├── fetch_external.ts
│ ├── logger.ts
│ └── proxy-request.ts
├── shared/
│ ├── readme.md
│ └── utils/
│ ├── helpers.ts
│ ├── html.ts
│ ├── index.ts
│ ├── renderer.ts
│ └── request.ts
├── store/
│ └── v2/
│ ├── article.ts
│ ├── assets.ts
│ ├── comment.ts
│ ├── comment_reply.ts
│ ├── db.ts
│ ├── debug.ts
│ ├── html.ts
│ ├── index.ts
│ ├── info.ts
│ ├── metadata.ts
│ ├── resource-map.ts
│ └── resource.ts
├── style.css
├── tailwind.config.js
├── test/
│ ├── common.ts
│ ├── normalize_html.ts
│ ├── parse_cgi_data.ts
│ ├── render_html_from_cgi_data.ts
│ └── validate_html_content.ts
├── todos.md
├── tsconfig.json
├── types/
│ ├── account.d.ts
│ ├── album.d.ts
│ ├── article.d.ts
│ ├── comment.d.ts
│ ├── credential.d.ts
│ ├── env.d.ts
│ ├── preferences.d.ts
│ ├── profile_getmsg.d.ts
│ ├── proxy.d.ts
│ ├── types.d.ts
│ └── video.d.ts
└── utils/
├── album.ts
├── comment.ts
├── download/
│ ├── BaseDownloader.ts
│ ├── Downloader.ts
│ ├── Exporter.ts
│ ├── ProxyManager.ts
│ ├── constants.ts
│ └── types.d.ts
├── exporter.ts
├── grid.ts
├── index.ts
├── pool.ts
└── readme.md
SYMBOL INDEX (571 symbols across 79 files)
FILE: apis/index.ts
function getArticleList (line 27) | async function getArticleList(
function getAccountList (line 79) | async function getAccountList(begin = 0, keyword = ''): Promise<[Account...
function getComment (line 106) | async function getComment(commentId: string) {
function getArticleListWithCredential (line 137) | async function getArticleListWithCredential(fakeid: string, begin = 0) {
FILE: composables/toast.ts
function success (line 4) | function success(title: string, description: string = '') {
function info (line 13) | function info(title: string, description: string = '') {
function warning (line 22) | function warning(title: string, description: string = '') {
function error (line 31) | function error(title: string, description: string = '') {
FILE: composables/useAccountEventBus.ts
type AccountEvent (line 6) | type AccountEvent = 'account-added' | 'account-removed';
type AccountEventPayload (line 7) | interface AccountEventPayload {
FILE: composables/useBatchDownload.ts
function useDownloadAlbum (line 10) | function useDownloadAlbum() {
FILE: composables/useDownloader.ts
type DownloadArticleOptions (line 7) | interface DownloadArticleOptions {
function downloadArticleHTML (line 37) | async function downloadArticleHTML(urls: string[]) {
function downloadArticleMetadata (line 94) | async function downloadArticleMetadata(urls: string[]) {
function downloadArticleComment (line 150) | async function downloadArticleComment(urls: string[]) {
function fixSingleFakeidTask (line 194) | async function fixSingleFakeidTask(urls: string[]) {
function download (line 240) | async function download(type: 'html' | 'metadata' | 'comment' | 'fakeid'...
function cleanupDownloader (line 252) | function cleanupDownloader() {
function stop (line 259) | function stop() {
FILE: composables/useExporter.ts
function export2excel (line 15) | async function export2excel(urls: string[]) {
function export2json (line 50) | async function export2json(urls: string[]) {
function export2html (line 85) | async function export2html(urls: string[]) {
function export2txt (line 130) | async function export2txt(urls: string[]) {
function export2markdown (line 167) | async function export2markdown(urls: string[]) {
function export2word (line 204) | async function export2word(urls: string[]) {
function export2pdf (line 241) | async function export2pdf(urls: string[]) {}
function exportFile (line 245) | function exportFile(
FILE: composables/useLoginCheck.ts
function checkLogin (line 8) | function checkLogin() {
FILE: composables/useSyncDeadline.ts
function getDeadline (line 8) | function getDeadline(): Dayjs {
function getSyncTimestamp (line 47) | function getSyncTimestamp() {
function getActualDateRange (line 51) | function getActualDateRange() {
function getSelectOptions (line 61) | function getSelectOptions() {
FILE: config/index.ts
constant ARTICLE_LIST_PAGE_SIZE (line 16) | const ARTICLE_LIST_PAGE_SIZE = 20;
constant ACCOUNT_LIST_PAGE_SIZE (line 21) | const ACCOUNT_LIST_PAGE_SIZE = 5;
constant ACCOUNT_TYPE (line 26) | const ACCOUNT_TYPE: Record<number, string> = {
constant CREDENTIAL_LIVE_MINUTES (line 35) | const CREDENTIAL_LIVE_MINUTES: number = 25;
constant CREDENTIAL_API_HOST (line 40) | const CREDENTIAL_API_HOST = 'http://127.0.0.1:8088';
constant IMAGE_PROXY (line 49) | const IMAGE_PROXY = '';
constant USER_AGENT (line 54) | const USER_AGENT =
constant MP_ORIGIN_TIMESTAMP (line 60) | const MP_ORIGIN_TIMESTAMP = dayjs('2012-08-23 00:00:00').unix();
constant ITEM_SHOW_TYPE (line 65) | const ITEM_SHOW_TYPE: Record<number, string> = {
constant EXTERNAL_API_SERVICE (line 79) | const EXTERNAL_API_SERVICE = 'https://my-cron-service.deno.dev';
FILE: config/public-proxy.ts
constant PUBLIC_PROXY_LIST (line 4) | const PUBLIC_PROXY_LIST: string[] = [
function getDomainProxyList (line 14) | function getDomainProxyList(domain: string): string[] {
FILE: public/custom-elements/mp-common-mpaudio.js
function formatDuration (line 1) | function formatDuration(duration) {
function pad (line 8) | function pad(num) {
function formatTimeGap (line 12) | function formatTimeGap(seconds) {
class MpCommonMpaudio (line 19) | class MpCommonMpaudio extends HTMLElement {
method constructor (line 20) | constructor() {
method name (line 39) | get name() {
method author (line 42) | get author() {
method src (line 45) | get src() {
method isaac2 (line 48) | get isaac2() {
method low_size (line 51) | get low_size() {
method source_size (line 54) | get source_size() {
method high_size (line 57) | get high_size() {
method play_length (line 60) | get play_length() {
method trans_state (line 63) | get trans_state() {
method verify_state (line 66) | get verify_state() {
method posIndex (line 69) | get posIndex() {
method duration (line 72) | get duration() {
method showListenLater (line 75) | get showListenLater() {
method hasAddedListenLater (line 78) | get hasAddedListenLater() {
method albumId (line 81) | get albumId() {
method albumTitle (line 84) | get albumTitle() {
method albumLink (line 87) | get albumLink() {
method albumNum (line 90) | get albumNum() {
method errTips (line 93) | get errTips() {
method fileid (line 96) | get fileid() {
method cover (line 99) | get cover() {
method dataPluginname (line 102) | get dataPluginname() {
method is_hover (line 105) | get is_hover() {
method is_selected (line 108) | get is_selected() {
method verifyErr (line 112) | get verifyErr() {
method duration_str (line 126) | get duration_str() {
method seconds (line 138) | get seconds() {
method _onClick (line 148) | _onClick() {
method playing (line 163) | get playing() {
method playing (line 166) | set playing(value) {
method stopped (line 180) | get stopped() {
method stopped (line 183) | set stopped(value) {
method connectedCallback (line 191) | connectedCallback() {
method disconnectedCallback (line 232) | disconnectedCallback() {
FILE: public/plugins/credential.py
class ExtractSetCookie (line 11) | class ExtractSetCookie:
method __init__ (line 12) | def __init__(self):
method response (line 15) | def response(self, flow: mitmproxy.http.HTTPFlow):
function start_http_server (line 47) | def start_http_server():
FILE: public/vendors/html-docx-js@0.3.1/html-docx.js
function s (line 1) | function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&re...
function Buffer (line 67) | function Buffer (subject, encoding, noZero) {
function hexWrite (line 306) | function hexWrite (buf, string, offset, length) {
function utf8Write (line 333) | function utf8Write (buf, string, offset, length) {
function asciiWrite (line 338) | function asciiWrite (buf, string, offset, length) {
function binaryWrite (line 343) | function binaryWrite (buf, string, offset, length) {
function base64Write (line 347) | function base64Write (buf, string, offset, length) {
function utf16leWrite (line 352) | function utf16leWrite (buf, string, offset, length) {
function base64Slice (line 421) | function base64Slice (buf, start, end) {
function utf8Slice (line 429) | function utf8Slice (buf, start, end) {
function asciiSlice (line 446) | function asciiSlice (buf, start, end) {
function binarySlice (line 456) | function binarySlice (buf, start, end) {
function hexSlice (line 460) | function hexSlice (buf, start, end) {
function utf16leSlice (line 473) | function utf16leSlice (buf, start, end) {
function checkOffset (line 521) | function checkOffset (offset, ext, length) {
function checkInt (line 632) | function checkInt (buf, value, offset, ext, max, min) {
function objectWriteUInt16 (line 648) | function objectWriteUInt16 (buf, value, offset, littleEndian) {
function objectWriteUInt32 (line 680) | function objectWriteUInt32 (buf, value, offset, littleEndian) {
function checkIEEE754 (line 779) | function checkIEEE754 (buf, value, offset, ext, max, min) {
function writeFloat (line 784) | function writeFloat (buf, value, offset, littleEndian, noAssert) {
function writeDouble (line 799) | function writeDouble (buf, value, offset, littleEndian, noAssert) {
function base64clean (line 966) | function base64clean (str) {
function stringtrim (line 976) | function stringtrim (str) {
function isArrayish (line 981) | function isArrayish (subject) {
function toHex (line 987) | function toHex (n) {
function utf8ToBytes (line 992) | function utf8ToBytes (str) {
function asciiToBytes (line 1010) | function asciiToBytes (str) {
function utf16leToBytes (line 1019) | function utf16leToBytes (str) {
function base64ToBytes (line 1033) | function base64ToBytes (str) {
function blitBuffer (line 1037) | function blitBuffer (src, dst, offset, length, unitSize) {
function decodeUtf8Char (line 1047) | function decodeUtf8Char (str) {
function decode (line 1071) | function decode (elt) {
function b64ToByteArray (line 1087) | function b64ToByteArray (b64) {
function uint8ToBase64 (line 1133) | function uint8ToBase64 (uint8) {
function ArrayReader (line 1302) | function ArrayReader(data) {
function CompressedObject (line 1425) | function CompressedObject() {
function DataReader (line 1576) | function DataReader(data) {
function JSZip (line 1843) | function JSZip(data, options) {
function NodeBufferReader (line 1957) | function NodeBufferReader(data) {
function StringReader (line 2862) | function StringReader(data, optimizedBinaryString) {
function Uint8ArrayReader (line 2970) | function Uint8ArrayReader(data) {
function identity (line 3291) | function identity(input) {
function stringToArrayLike (line 3301) | function stringToArrayLike(str, array) {
function arrayLikeToString (line 3313) | function arrayLikeToString(array) {
function arrayLikeToArrayLike (line 3377) | function arrayLikeToArrayLike(arrayFrom, arrayTo) {
function ZipEntries (line 3605) | function ZipEntries(data, loadOptions) {
function ZipEntry (line 3887) | function ZipEntry(options, loadOptions) {
function Deflate (line 4326) | function Deflate(options) {
function deflate (line 4561) | function deflate(input, options) {
function deflateRaw (line 4581) | function deflateRaw(input, options) {
function gzip (line 4596) | function gzip(input, options) {
function Inflate (line 4701) | function Inflate(options) {
function inflate (line 4986) | function inflate(input, options) {
function inflateRaw (line 5006) | function inflateRaw(input, options) {
function buf2binstring (line 5217) | function buf2binstring(buf, len) {
function adler32 (line 5326) | function adler32(adler, buf, len, pos) {
function makeTable (line 5414) | function makeTable() {
function crc32 (line 5432) | function crc32(crc, buf, len, pos) {
function err (line 5554) | function err(strm, errorCode) {
function rank (line 5559) | function rank(f) {
function zero (line 5563) | function zero(buf) { var len = buf.length; while (--len >= 0) { buf[len]...
function flush_pending (line 5572) | function flush_pending(strm) {
function flush_block_only (line 5594) | function flush_block_only(s, last) {
function put_byte (line 5601) | function put_byte(s, b) {
function putShortMSB (line 5611) | function putShortMSB(s, b) {
function read_buf (line 5626) | function read_buf(strm, buf, start, size) {
function longest_match (line 5660) | function longest_match(s, cur_match) {
function fill_window (line 5773) | function fill_window(s) {
function deflate_stored (line 5929) | function deflate_stored(s, flush) {
function deflate_fast (line 6027) | function deflate_fast(s, flush) {
function deflate_slow (line 6155) | function deflate_slow(s, flush) {
function deflate_rle (line 6317) | function deflate_rle(s, flush) {
function deflate_huff (line 6412) | function deflate_huff(s, flush) {
function Config (line 6469) | function Config(good_length, max_lazy, nice_length, max_chain, func) {
function lm_init (line 6498) | function lm_init(s) {
function DeflateState (line 6521) | function DeflateState() {
function deflateResetKeep (line 6710) | function deflateResetKeep(strm) {
function deflateReset (line 6739) | function deflateReset(strm) {
function deflateSetHeader (line 6748) | function deflateSetHeader(strm, head) {
function deflateInit2 (line 6756) | function deflateInit2(strm, level, method, windowBits, memLevel, strateg...
function deflateInit (line 6827) | function deflateInit(strm, level) {
function deflate (line 6832) | function deflate(strm, flush) {
function deflateEnd (line 7166) | function deflateEnd(strm) {
function deflateSetDictionary (line 7195) | function deflateSetDictionary(strm, dictionary) {
function GZheader (line 7302) | function GZheader() {
function zswap32 (line 7763) | function zswap32(q) {
function InflateState (line 7771) | function InflateState() {
function inflateResetKeep (line 7829) | function inflateResetKeep(strm) {
function inflateReset (line 7856) | function inflateReset(strm) {
function inflateReset2 (line 7868) | function inflateReset2(strm, windowBits) {
function inflateInit2 (line 7902) | function inflateInit2(strm, windowBits) {
function inflateInit (line 7922) | function inflateInit(strm) {
function fixedtables (line 7941) | function fixedtables(state) {
function updatewindow (line 7989) | function updatewindow(strm, src, end, copy) {
function inflate (line 8031) | function inflate(strm, flush) {
function inflateEnd (line 9123) | function inflateEnd(strm) {
function inflateGetHeader (line 9137) | function inflateGetHeader(strm, head) {
function inflateSetDictionary (line 9151) | function inflateSetDictionary(strm, dictionary) {
function zero (line 9577) | function zero(buf) { var len = buf.length; while (--len >= 0) { buf[len]...
function StaticTreeDesc (line 9700) | function StaticTreeDesc(static_tree, extra_bits, extra_base, elems, max_...
function TreeDesc (line 9718) | function TreeDesc(dyn_tree, stat_desc) {
function d_code (line 9726) | function d_code(dist) {
function put_short (line 9735) | function put_short(s, w) {
function send_bits (line 9747) | function send_bits(s, value, length) {
function send_code (line 9760) | function send_code(s, c, tree) {
function bi_reverse (line 9770) | function bi_reverse(code, len) {
function bi_flush (line 9784) | function bi_flush(s) {
function gen_bitlen (line 9808) | function gen_bitlen(s, desc)
function gen_codes (line 9905) | function gen_codes(tree, max_code, bl_count)
function tr_static_init (line 9943) | function tr_static_init() {
function init_block (line 10047) | function init_block(s) {
function bi_windup (line 10064) | function bi_windup(s)
function copy_block (line 10080) | function copy_block(s, buf, len, header)
function smaller (line 10103) | function smaller(tree, n, m, depth) {
function pqdownheap (line 10116) | function pqdownheap(s, tree, k)
function compress_block (line 10149) | function compress_block(s, ltree, dtree)
function build_tree (line 10209) | function build_tree(s, desc)
function scan_tree (line 10305) | function scan_tree(s, tree, max_code)
function send_tree (line 10371) | function send_tree(s, tree, max_code)
function build_bl_tree (line 10442) | function build_bl_tree(s) {
function send_all_trees (line 10478) | function send_all_trees(s, lcodes, dcodes, blcodes)
function detect_data_type (line 10518) | function detect_data_type(s) {
function _tr_init (line 10556) | function _tr_init(s)
function _tr_stored_block (line 10579) | function _tr_stored_block(s, buf, stored_len, last)
function _tr_align (line 10594) | function _tr_align(s) {
function _tr_flush_block (line 10605) | function _tr_flush_block(s, buf, stored_len, last)
function _tr_tally (line 10692) | function _tr_tally(s, dist, lc)
function ZStream (line 10760) | function ZStream() {
function escapeHtmlChar (line 10825) | function escapeHtmlChar(chr) {
function isObjectLike (line 10868) | function isObjectLike(value) {
function isSymbol (line 10888) | function isSymbol(value) {
function toString (line 10913) | function toString(value) {
function escape (line 10960) | function escape(string) {
function checkGlobal (line 11025) | function checkGlobal(value) {
function isObjectLike (line 11058) | function isObjectLike(value) {
function baseMerge (line 11080) | function baseMerge(object, source, customizer, stackA, stackB) {
function baseMergeDeep (line 11129) | function baseMergeDeep(object, source, key, mergeFunc, customizer, stack...
function baseProperty (line 11179) | function baseProperty(key) {
function isArrayLike (line 11204) | function isArrayLike(value) {
function isLength (line 11217) | function isLength(value) {
function isObject (line 11241) | function isObject(value) {
function arrayCopy (line 11318) | function arrayCopy(source, array) {
function arrayEach (line 11350) | function arrayEach(array, iteratee) {
function createAssigner (line 11387) | function createAssigner(assigner) {
function bindCallback (line 11438) | function bindCallback(func, thisArg, argCount) {
function identity (line 11479) | function identity(value) {
function baseProperty (line 11511) | function baseProperty(key) {
function isArrayLike (line 11536) | function isArrayLike(value) {
function isIndex (line 11548) | function isIndex(value, length) {
function isIterateeCall (line 11563) | function isIterateeCall(value, index, object) {
function isLength (line 11586) | function isLength(value) {
function isObject (line 11610) | function isObject(value) {
function restParam (line 11657) | function restParam(func, start) {
function isObjectLike (line 11711) | function isObjectLike(value) {
function getNative (line 11744) | function getNative(object, key) {
function isFunction (line 11765) | function isFunction(value) {
function isObject (line 11792) | function isObject(value) {
function isNative (line 11815) | function isNative(value) {
function baseProperty (line 11867) | function baseProperty(key) {
function isArguments (line 11901) | function isArguments(value) {
function isArrayLike (line 11931) | function isArrayLike(value) {
function isArrayLikeObject (line 11958) | function isArrayLikeObject(value) {
function isFunction (line 11978) | function isFunction(value) {
function isLength (line 12010) | function isLength(value) {
function isObject (line 12038) | function isObject(value) {
function isObjectLike (line 12066) | function isObjectLike(value) {
function isObjectLike (line 12096) | function isObjectLike(value) {
function getNative (line 12138) | function getNative(object, key) {
function isLength (line 12152) | function isLength(value) {
function isFunction (line 12192) | function isFunction(value) {
function isObject (line 12219) | function isObject(value) {
function isNative (line 12242) | function isNative(value) {
function isObjectLike (line 12277) | function isObjectLike(value) {
function baseForIn (line 12302) | function baseForIn(object, iteratee) {
function isPlainObject (line 12336) | function isPlainObject(value) {
function createBaseFor (line 12390) | function createBaseFor(fromRight) {
function isLength (line 12501) | function isLength(value) {
function isObjectLike (line 12530) | function isObjectLike(value) {
function isTypedArray (line 12552) | function isTypedArray(value) {
function baseProperty (line 12597) | function baseProperty(key) {
function isArrayLike (line 12622) | function isArrayLike(value) {
function isIndex (line 12634) | function isIndex(value, length) {
function isLength (line 12649) | function isLength(value) {
function shimKeys (line 12661) | function shimKeys(object) {
function isObject (line 12701) | function isObject(value) {
function keysIn (line 12766) | function keysIn(object) {
function isIndex (line 12832) | function isIndex(value, length) {
function isLength (line 12847) | function isLength(value) {
function isObject (line 12871) | function isObject(value) {
function keysIn (line 12900) | function keysIn(object) {
function toPlainObject (line 12966) | function toPlainObject(value) {
function baseCopy (line 12991) | function baseCopy(source, props, object) {
FILE: server/api/_debug.get.ts
type DebugQuery (line 3) | interface DebugQuery {
FILE: server/api/public/beta/aboutbiz.get.ts
type AboutBizQuery (line 5) | interface AboutBizQuery {
constant USER_AGENT (line 10) | const USER_AGENT =
function extractInfo (line 54) | function extractInfo(rawHTML: string) {
FILE: server/api/public/beta/authorinfo.get.ts
type AuthorInfoQuery (line 7) | interface AuthorInfoQuery {
FILE: server/api/public/v1/account.get.ts
type SearchBizQuery (line 4) | interface SearchBizQuery {
FILE: server/api/public/v1/accountbyurl.get.ts
type UrlQuery (line 3) | interface UrlQuery {
FILE: server/api/public/v1/article.get.ts
type AppMsgPublishQuery (line 4) | interface AppMsgPublishQuery {
FILE: server/api/public/v1/download.get.ts
type SearchBizQuery (line 6) | interface SearchBizQuery {
FILE: server/api/web/misc/accountname.get.ts
type AccountNameQuery (line 4) | interface AccountNameQuery {
constant ALLOWED_HOSTS (line 9) | const ALLOWED_HOSTS = new Set(['mp.weixin.qq.com', 'weixin.qq.com']);
function isAllowedUrl (line 14) | function isAllowedUrl(rawUrl: string): boolean {
FILE: server/api/web/misc/appmsgalbum.get.ts
type AppMsgAlbumQuery (line 7) | interface AppMsgAlbumQuery {
FILE: server/api/web/misc/comment.get.ts
type GetCommentQuery (line 7) | interface GetCommentQuery {
FILE: server/api/web/misc/current-ip.get.ts
function getClientIp (line 15) | function getClientIp(event: H3Event) {
FILE: server/api/web/mp/appmsgpublish.get.ts
type AppMsgPublishQuery (line 8) | interface AppMsgPublishQuery {
FILE: server/api/web/mp/profile_ext_getmsg.get.ts
type SearchBizQuery (line 7) | interface SearchBizQuery {
FILE: server/api/web/mp/searchbiz.get.ts
type SearchBizQuery (line 8) | interface SearchBizQuery {
FILE: server/api/web/mp/searchbyurl.get.ts
type UrlQuery (line 3) | interface UrlQuery {
FILE: server/api/web/worker/security-top-n.get.ts
type NameQuery (line 7) | interface NameQuery {
FILE: server/kv/cookie.ts
type CookieKVKey (line 3) | type CookieKVKey = string;
type CookieKVValue (line 5) | interface CookieKVValue {
function setMpCookie (line 10) | async function setMpCookie(key: CookieKVKey, data: CookieKVValue): Promi...
function getMpCookie (line 24) | async function getMpCookie(key: CookieKVKey): Promise<CookieKVValue | nu...
FILE: server/types.d.ts
type Method (line 3) | type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type RequestOptions (line 5) | interface RequestOptions {
FILE: server/utils/CookieStore.ts
type CookieEntity (line 5) | type CookieEntity = Record<string, string | number>;
class AccountCookie (line 8) | class AccountCookie {
method constructor (line 16) | constructor(token: string, cookies: string[]) {
method create (line 21) | static create(token: string, cookies: CookieEntity[]): AccountCookie {
method toString (line 27) | public toString(): string {
method toJSON (line 31) | public toJSON(): CookieKVValue {
method get (line 38) | public get(name: string): CookieEntity | undefined {
method token (line 42) | public get token() {
method isExpired (line 47) | public get isExpired(): boolean {
method parse (line 52) | public static parse(cookies: string[]): CookieEntity[] {
method stringify (line 101) | private stringify(parsedCookie: CookieEntity[]): string {
class CookieStore (line 110) | class CookieStore {
method getAccountCookie (line 118) | async getAccountCookie(authKey: string): Promise<AccountCookie | null> {
method getCookie (line 147) | async getCookie(authKey: string): Promise<string | null> {
method setCookie (line 161) | async setCookie(authKey: string, token: string, cookie: string[]): Pro...
method removeCookie (line 174) | removeCookie(authKey: string): void {
method evictIfNeeded (line 181) | private evictIfNeeded(): void {
method getToken (line 197) | async getToken(authKey: string): Promise<string | null> {
method toJSON (line 210) | toJSON(): Record<string, AccountCookie> {
function getCookieFromStore (line 227) | async function getCookieFromStore(event: H3Event): Promise<string | null> {
function getTokenFromStore (line 258) | async function getTokenFromStore(event: H3Event): Promise<string | null> {
function getCookiesFromRequest (line 289) | function getCookiesFromRequest(event: H3Event): string {
function getCookieFromResponse (line 301) | function getCookieFromResponse(name: string, response: Response): string...
FILE: server/utils/fetch_external.ts
type FetchExternalOption (line 1) | interface FetchExternalOption {
function fetchExternal (line 6) | async function fetchExternal(url: string, option: FetchExternalOption): ...
FILE: server/utils/logger.ts
function logToFile (line 9) | function logToFile(prefix: string, message: string) {
function logRequest (line 22) | async function logRequest(requestId: string, request: Request) {
function logResponse (line 39) | async function logResponse(requestId: string, response: Response) {
FILE: server/utils/proxy-request.ts
function proxyMpRequest (line 14) | async function proxyMpRequest(options: RequestOptions) {
function getAuthKeyFromRequest (line 146) | function getAuthKeyFromRequest(event: H3Event): string {
FILE: shared/utils/helpers.ts
function sleep (line 4) | function sleep(ms: number = 1000): Promise<void> {
function timeout (line 8) | function timeout(ms: number = 1000): Promise<never> {
function throwException (line 14) | function throwException(message: string) {
function maxLen (line 18) | function maxLen(text: string, max = 35): string {
function filterInvalidFilenameChars (line 26) | function filterInvalidFilenameChars(input: string): string {
function formatElapsedTime (line 33) | function formatElapsedTime(seconds: number): string {
function durationToSeconds (line 52) | function durationToSeconds(duration: string | undefined) {
function formatTimeStamp (line 59) | function formatTimeStamp(timestamp: number) {
function formatItemShowType (line 64) | function formatItemShowType(type: number) {
FILE: shared/utils/html.ts
function normalizeHtml (line 12) | function normalizeHtml(rawHTML: string, format: 'html' | 'text' = 'html'...
function validateHTMLContent (line 101) | function validateHTMLContent(html: string): ['Success' | 'Deleted' | 'Ex...
function extractCgiScript (line 133) | function extractCgiScript(html: string) {
function parseCgiDataNewOnClient (line 154) | function parseCgiDataNewOnClient(html: string): Promise<any> {
function parseCgiDataNewOnServerDeprecated (line 186) | function parseCgiDataNewOnServerDeprecated(html: string): Promise<any> {
function parseCgiDataNewOnServer (line 212) | async function parseCgiDataNewOnServer(html: string): Promise<any> {
function parseCgiDataNew (line 238) | async function parseCgiDataNew(html: string): Promise<any> {
FILE: shared/utils/index.ts
function urlIsValidMpArticle (line 5) | function urlIsValidMpArticle(url: string) {
FILE: shared/utils/renderer.ts
constant ITEM_SHOW_TYPE (line 6) | const ITEM_SHOW_TYPE = {
function renderHTMLFromCgiDataNew (line 17) | async function renderHTMLFromCgiDataNew(cgiData: any, comments = true) {
function extractTitle (line 177) | function extractTitle(cgiData: any): string {
function extractContentHTML (line 202) | function extractContentHTML(cgiData: any): string {
function renderContent_8 (line 225) | function renderContent_8(cgiData: any): string {
function renderContent_10 (line 257) | function renderContent_10(cgiData: any): string {
function renderContent_0 (line 270) | function renderContent_0(cgiData: any): string {
function renderTextFromCgiDataNew (line 306) | function renderTextFromCgiDataNew(cgiData: any): string {
function renderMetaInfo (line 332) | function renderMetaInfo(cgiData: any): string {
function renderBottomBar (line 346) | async function renderBottomBar(cgiData: any) {
FILE: shared/utils/request.ts
method onResponse (line 7) | async onResponse({ request, response, options, error }) {
method onResponseError (line 10) | async onResponseError({ request, response, options, error }) {}
FILE: store/v2/article.ts
type ArticleAsset (line 5) | type ArticleAsset = AppMsgExWithFakeID;
function updateArticleCache (line 12) | async function updateArticleCache(account: MpAccount, publish_page: Publ...
function hitCache (line 59) | async function hitCache(fakeid: string, create_time: number): Promise<bo...
function getArticleCache (line 73) | async function getArticleCache(fakeid: string, create_time: number): Pro...
function getArticleByLink (line 86) | async function getArticleByLink(url: string): Promise<AppMsgExWithFakeID> {
function getSingleArticleByLink (line 95) | async function getSingleArticleByLink(url: string): Promise<AppMsgExWith...
function articleDeleted (line 113) | async function articleDeleted(url: string, is_deleted = true): Promise<v...
function updateArticleStatus (line 129) | async function updateArticleStatus(url: string, status: string): Promise...
function updateArticleFakeid (line 145) | async function updateArticleFakeid(url: string, fakeid: string): Promise...
FILE: store/v2/assets.ts
type Asset (line 3) | interface Asset {
function updateAssetCache (line 15) | async function updateAssetCache(asset: Asset): Promise<boolean> {
function getAssetCache (line 26) | async function getAssetCache(url: string): Promise<Asset | undefined> {
FILE: store/v2/comment.ts
type CommentAsset (line 3) | interface CommentAsset {
function updateCommentCache (line 14) | async function updateCommentCache(comment: CommentAsset): Promise<boolea...
function getCommentCache (line 25) | async function getCommentCache(url: string): Promise<CommentAsset | unde...
FILE: store/v2/comment_reply.ts
type CommentReplyAsset (line 3) | interface CommentReplyAsset {
function updateCommentReplyCache (line 15) | async function updateCommentReplyCache(reply: CommentReplyAsset): Promis...
function getCommentReplyCache (line 27) | async function getCommentReplyCache(url: string, contentID: string): Pro...
FILE: store/v2/debug.ts
type DebugAsset (line 3) | interface DebugAsset {
function updateDebugCache (line 15) | async function updateDebugCache(html: DebugAsset): Promise<boolean> {
function getDebugCache (line 26) | async function getDebugCache(url: string): Promise<DebugAsset | undefine...
function getDebugInfo (line 30) | async function getDebugInfo(): Promise<DebugAsset[]> {
FILE: store/v2/html.ts
type HtmlAsset (line 3) | interface HtmlAsset {
function updateHtmlCache (line 15) | async function updateHtmlCache(html: HtmlAsset): Promise<boolean> {
function getHtmlCache (line 26) | async function getHtmlCache(url: string): Promise<HtmlAsset | undefined> {
FILE: store/v2/index.ts
function deleteAccountData (line 4) | async function deleteAccountData(ids: string[]): Promise<void> {
FILE: store/v2/info.ts
type MpAccount (line 3) | interface MpAccount {
function updateInfoCache (line 27) | async function updateInfoCache(mpAccount: MpAccount): Promise<boolean> {
function updateLastUpdateTime (line 58) | async function updateLastUpdateTime(fakeid: string): Promise<boolean> {
function getInfoCache (line 73) | async function getInfoCache(fakeid: string): Promise<MpAccount | undefin...
function getAllInfo (line 77) | async function getAllInfo(): Promise<MpAccount[]> {
function getAccountNameByFakeid (line 82) | async function getAccountNameByFakeid(fakeid: string): Promise<string | ...
function importMpAccounts (line 92) | async function importMpAccounts(mpAccounts: MpAccount[]): Promise<void> {
FILE: store/v2/metadata.ts
type Metadata (line 4) | type Metadata = ArticleMetadata & {
function updateMetadataCache (line 14) | async function updateMetadataCache(metadata: Metadata): Promise<boolean> {
function getMetadataCache (line 25) | async function getMetadataCache(url: string): Promise<Metadata | undefin...
FILE: store/v2/resource-map.ts
type ResourceMapAsset (line 3) | interface ResourceMapAsset {
function updateResourceMapCache (line 13) | async function updateResourceMapCache(resourceMap: ResourceMapAsset): Pr...
function getResourceMapCache (line 24) | async function getResourceMapCache(url: string): Promise<ResourceMapAsse...
FILE: store/v2/resource.ts
type ResourceAsset (line 3) | interface ResourceAsset {
function updateResourceCache (line 13) | async function updateResourceCache(resource: ResourceAsset): Promise<boo...
function getResourceCache (line 24) | async function getResourceCache(url: string): Promise<ResourceAsset | un...
FILE: test/common.ts
type HtmlSampleGroup (line 6) | interface HtmlSampleGroup {
function read (line 84) | function read(filepath: string) {
function write (line 88) | function write(filepath: string, data: any) {
FILE: test/normalize_html.ts
function normalizeOutPath (line 4) | function normalizeOutPath(input: string): string {
function run (line 10) | function run() {
FILE: test/parse_cgi_data.ts
function normalizeOutPath (line 4) | function normalizeOutPath(input: string): string {
function run (line 10) | async function run() {
FILE: test/render_html_from_cgi_data.ts
function normalizeOutPath (line 5) | function normalizeOutPath(input: string): string {
function run (line 11) | async function run() {
FILE: test/validate_html_content.ts
function run (line 4) | function run() {
FILE: types/account.d.ts
type AccountManifest (line 3) | interface AccountManifest {
FILE: types/album.d.ts
type BaseResp (line 3) | interface BaseResp {
type BooleanString (line 7) | type BooleanString = '0' | '1';
type BaseInfo (line 9) | interface BaseInfo {
type ArticleItem (line 33) | interface ArticleItem {
type GetAlbumResp (line 50) | interface GetAlbumResp {
type AppMsgAlbumResult (line 58) | interface AppMsgAlbumResult {
FILE: types/article.d.ts
type Article (line 4) | interface Article extends AppMsgExWithFakeID, Partial<ArticleMetadata> {
FILE: types/comment.d.ts
type BaseResp (line 1) | interface BaseResp {
type IPWording (line 6) | interface IPWording {
type Comment (line 15) | interface Comment {
type ReplyComment (line 43) | interface ReplyComment {
type CommentResponse (line 63) | interface CommentResponse {
type ReplyList (line 83) | interface ReplyList {
type ReplyResponse (line 88) | interface ReplyResponse {
FILE: types/credential.d.ts
type ParsedCredential (line 1) | interface ParsedCredential {
FILE: types/env.d.ts
type Window (line 1) | interface Window {
FILE: types/preferences.d.ts
type Preferences (line 2) | interface Preferences {
type ExportConfig (line 25) | interface ExportConfig {
type DownloadConfig (line 45) | interface DownloadConfig {
FILE: types/profile_getmsg.d.ts
type ProfileGetMsgResponse (line 1) | interface ProfileGetMsgResponse {
type app_msg_item (line 13) | interface app_msg_item {
type ProfileGetMsg_app_msg_ext_info (line 32) | interface ProfileGetMsg_app_msg_ext_info extends app_msg_item {
type ProfileGetMsg_comm_msg_info (line 38) | interface ProfileGetMsg_comm_msg_info {
type ParsedProfileGetMsg (line 47) | interface ParsedProfileGetMsg {
FILE: types/proxy.d.ts
type Base (line 1) | interface Base {
type Metric (line 6) | interface Metric {
type AccountMetric (line 12) | interface AccountMetric extends Base {
FILE: types/types.d.ts
type LoginAccount (line 1) | interface LoginAccount {
type BaseResp (line 8) | interface BaseResp {
type StartLoginResult (line 13) | interface StartLoginResult {
type GetAuthKeyResult (line 17) | interface GetAuthKeyResult {
type ScanLoginResult (line 23) | interface ScanLoginResult {
type AccountInfo (line 30) | interface AccountInfo {
type SearchBizResponse (line 41) | interface SearchBizResponse {
type AppMsgPublishResponse (line 47) | interface AppMsgPublishResponse {
type PublishListItem (line 52) | interface PublishListItem {
type PublishPage (line 57) | interface PublishPage {
type PublishInfo (line 77) | interface PublishInfo {
type ArticleSentInfo (line 90) | interface ArticleSentInfo {
type ArticleSentResult (line 97) | interface ArticleSentResult {
type ArticleSentStatus (line 104) | interface ArticleSentStatus {
type AppMsgInfo (line 112) | interface AppMsgInfo {
type RGB (line 123) | interface RGB {
type AppMsgAlbumInfo (line 129) | interface AppMsgAlbumInfo {
type AppMsgEx (line 136) | interface AppMsgEx {
type AppMsgExWithFakeID (line 167) | type AppMsgExWithFakeID = AppMsgEx & {
type DownloadableArticle (line 177) | interface DownloadableArticle {
type LogoutResponse (line 186) | interface LogoutResponse {
FILE: types/video.d.ts
type VideoPageInfo (line 1) | interface VideoPageInfo {
type VideoTransInfo (line 15) | interface VideoTransInfo {
type AudioResource (line 26) | interface AudioResource {
type VideoResource (line 32) | interface VideoResource {
FILE: utils/album.ts
function padLeft (line 4) | function padLeft(num: number, len: number = 2) {
function formatAlbumTime (line 11) | function formatAlbumTime(timestamp: number) {
function theme (line 65) | function theme(article: ArticleItem) {
FILE: utils/comment.ts
function extractCommentId (line 10) | function extractCommentId(html: string): string | null {
function renderComments (line 42) | async function renderComments(url: string) {
function getArticleComments (line 153) | async function getArticleComments(url: string) {
FILE: utils/download/BaseDownloader.ts
class BaseDownloader (line 20) | class BaseDownloader {
method constructor (line 33) | constructor(urls: string[], options: DownloadOptions = {}) {
method on (line 67) | public on(type: string, listener: Callback) {
method off (line 79) | public off(type: string, listener?: Callback) {
method removeAllListeners (line 96) | public removeAllListeners() {
method cancelAllPending (line 103) | public cancelAllPending(): void {
method getStatus (line 111) | public getStatus(): DownloaderStatus {
method emit (line 122) | protected emit(type: string, ...args: any[]) {
method handleDownloadFailure (line 131) | protected async handleDownloadFailure(proxy: string, url: string, atte...
method download (line 143) | protected async download(fakeid: string, url: string, proxy: string, w...
method validateInputs (line 179) | protected validateInputs(urls: string[]): void {
method validateCredential (line 190) | protected validateCredential(fakeid: string): void {
FILE: utils/download/Downloader.ts
type DownloadType (line 17) | type DownloadType = 'html' | 'metadata' | 'comments' | 'fakeid';
class Downloader (line 22) | class Downloader extends BaseDownloader {
method constructor (line 28) | constructor(urls: string[], options: DownloadOptions = {}) {
method startDownload (line 33) | public async startDownload(type: DownloadType) {
method stop (line 58) | public stop() {
method processDownloadQueue (line 63) | private async processDownloadQueue() {
method processTask (line 98) | private async processTask(url: string) {
method fixSingleFakeidTask (line 111) | private async fixSingleFakeidTask(url: string) {
method downloadHTMLTask (line 146) | private async downloadHTMLTask(url: string): Promise<void> {
method downloadMetadataTask (line 239) | private async downloadMetadataTask(url: string): Promise<void> {
method downloadCommentsTask (line 336) | private async downloadCommentsTask(url: string): Promise<void> {
method fetchComments (line 455) | private async fetchComments(
method fetchCommentReply (line 493) | private async fetchCommentReply(
method processHtmlMetadata (line 532) | private async processHtmlMetadata(blob: Blob, url: string): Promise<vo...
FILE: utils/download/Exporter.ts
type ExportType (line 21) | type ExportType = 'excel' | 'json' | 'html' | 'txt' | 'markdown' | 'word...
class Exporter (line 25) | class Exporter extends BaseDownloader {
method constructor (line 33) | constructor(urls: string[], options: DownloadOptions = {}) {
method startExport (line 39) | public async startExport(type: ExportType = 'html') {
method extractResources (line 94) | private async extractResources(): Promise<void> {
method processExportQueue (line 154) | private async processExportQueue() {
method processFileExportQueue (line 189) | private async processFileExportQueue(
method downloadResourceTask (line 225) | private async downloadResourceTask(url: string, fakeid: string): Promi...
method exportExcelFiles (line 260) | private async exportExcelFiles(): Promise<void> {
method exportJsonFiles (line 293) | private async exportJsonFiles(): Promise<void> {
method exportHtmlFiles (line 331) | private async exportHtmlFiles() {
method exportTxtFiles (line 381) | private async exportTxtFiles() {
method exportMarkdownFiles (line 399) | private async exportMarkdownFiles() {
method exportWordFiles (line 420) | private async exportWordFiles() {
method exportPdfFiles (line 438) | private async exportPdfFiles() {}
method getRenderedHTML (line 444) | private async getRenderedHTML(url: string, comments = false): Promise<...
method getRenderedText (line 463) | private async getRenderedText(url: string): Promise<string> {
method getHtmlContent (line 478) | static async getHtmlContent(url: string) {
method normalizeHtml (line 484) | private async normalizeHtml(
method acquireExportDirectoryHandle (line 837) | private async acquireExportDirectoryHandle(): Promise<void> {
method writeFile (line 848) | public async writeFile(path: string, file: Blob): Promise<void> {
method exportDirName (line 869) | private async exportDirName(articleUrl: string): Promise<string> {
FILE: utils/download/ProxyManager.ts
class ProxyManager (line 4) | class ProxyManager {
method constructor (line 10) | constructor(
method initProxyStatus (line 26) | private initProxyStatus(): void {
method getBestProxy (line 40) | public getBestProxy(): string {
method resetAndGetProxy (line 62) | private resetAndGetProxy(): string {
method recordFailure (line 79) | public recordFailure(proxy: string): void {
method recordSuccess (line 91) | public recordSuccess(proxy: string): void {
method getProxyStatus (line 101) | public getProxyStatus(): Map<string, ProxyStatus> {
FILE: utils/download/constants.ts
constant DEFAULT_OPTIONS (line 2) | const DEFAULT_OPTIONS = {
FILE: utils/download/types.d.ts
type ProxyStatus (line 2) | interface ProxyStatus {
type DownloadOptions (line 12) | interface DownloadOptions {
type DownloadResult (line 25) | interface DownloadResult {
type ResourceSelectors (line 32) | type ResourceSelectors = {
type Callback (line 36) | type Callback = (...args: any[]) => void;
type DownloaderStatus (line 38) | interface DownloaderStatus {
type ExporterStatus (line 46) | interface ExporterStatus {
type ArticleMetadata (line 53) | interface ArticleMetadata {
FILE: utils/exporter.ts
type ExcelExportEntity (line 8) | type ExcelExportEntity = AppMsgEx &
function export2ExcelFile (line 16) | async function export2ExcelFile(data: ExcelExportEntity[], filename: str...
function export2JsonFile (line 74) | async function export2JsonFile(data: ExcelExportEntity[], filename: stri...
function exportAccountJsonFile (line 80) | async function exportAccountJsonFile(data: AccountManifest, filename: st...
FILE: utils/grid.ts
function createBooleanColumnFilterParams (line 11) | function createBooleanColumnFilterParams(trueLabel: string, falseLabel: ...
function createDateColumnFilterParams (line 24) | function createDateColumnFilterParams(): IDateFilterParams {
function createTextColumnFilterParams (line 41) | function createTextColumnFilterParams(): ITextFilterParams {
function createNumberValueGetter (line 49) | function createNumberValueGetter(field: keyof Article) {
function createBooleanValueGetter (line 60) | function createBooleanValueGetter(field: keyof Article) {
FILE: utils/index.ts
function downloadAssetWithProxy (line 19) | async function downloadAssetWithProxy<T extends Blob | string>(
function downloadArticleHTML (line 48) | async function downloadArticleHTML(articleURL: string, title?: string) {
function downloadArticleHTMLs (line 87) | async function downloadArticleHTMLs(articles: DownloadableArticle[], cal...
function packHTMLAssets (line 128) | async function packHTMLAssets(fakeid: string, html: string, title: strin...
function gotoLink (line 895) | function gotoLink(url: string) {
function bestConcurrencyCount (line 900) | function bestConcurrencyCount(proxyCount: number): number {
function isChromeBrowser (line 905) | function isChromeBrowser() {
FILE: utils/pool.ts
type ProxyInstance (line 12) | interface ProxyInstance {
type DownloadResource (line 39) | type DownloadResource =
type DownloadFn (line 48) | type DownloadFn<T extends DownloadResource> = (resource: T, proxy: strin...
type DownloadResult (line 51) | interface DownloadResult {
function now (line 68) | function now() {
class ProxyPool (line 72) | class ProxyPool {
method constructor (line 75) | constructor(proxyUrls: string[]) {
method init (line 92) | init(proxyUrls: string[] = []) {
method getAvailableProxy (line 118) | async getAvailableProxy() {
method releaseProxy (line 143) | releaseProxy(proxy: ProxyInstance, success: boolean) {
method removeProxy (line 168) | removeProxy(proxy: ProxyInstance) {
function downloadResource (line 182) | async function downloadResource<T extends DownloadResource>(
function downloadWithRetry (line 204) | async function downloadWithRetry<T extends DownloadResource>(
function download (line 273) | async function download<T extends DownloadResource>(resource: T, downloa...
function downloads (line 283) | async function downloads<T extends DownloadResource>(
Copy disabled (too large)
Download .json
Condensed preview — 222 files, each showing path, character count, and a content snippet. Download the .json file for the full structured content (69,204K chars).
[
{
"path": ".dockerignore",
"chars": 260,
"preview": "### NuxtJS template\n# Generated dirs\ndist\n\n.nuxt\n.nuxt-*\n.output\n.gen\n.yarn/cache # Yarn 缓存\nyarn-error.log\n\n# Node depe"
},
{
"path": ".env.example",
"chars": 232,
"preview": "# 调试微信代理请求 (仅开发环境(development)支持)\nNUXT_DEBUG_MP_REQUEST=false\n\n# AG-Grid 企业版授权\nNUXT_AGGRID_LICENSE=\n\n# KV绑定(本地/docker)\nN"
},
{
"path": ".github/workflows/docker.yml",
"chars": 1864,
"preview": "name: Build and Push Multi-Arch Docker Image\n\n# 触发条件:推送 v* 标签时(如 git tag v1.1.0 && git push --tags)\non:\n push:\n tags"
},
{
"path": ".gitignore",
"chars": 251,
"preview": "# Nuxt dev/build outputs\n.output\n.data\n.nuxt\n.nitro\n.cache\ndist\n\n# Node dependencies\nnode_modules\n\n# Logs\nlogs\n*.log\n\n# "
},
{
"path": ".prettierrc",
"chars": 552,
"preview": "{\n \"arrowParens\": \"avoid\",\n \"bracketSameLine\": false,\n \"bracketSpacing\": true,\n \"semi\": true,\n \"experimentalTernari"
},
{
"path": "CLAUDE.md",
"chars": 2270,
"preview": "# CLAUDE.md\n\n本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。\n\n## 项目概述\n\n微信公众号文章批量下载导出工具。基于 Nuxt 3(关闭 SSR,纯客户端 SPA)+ Vue 3"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 5222,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participa"
},
{
"path": "CONTRIBUTING.md",
"chars": 1257,
"preview": "# 贡献指南\n\n感谢您对本项目的兴趣!我们非常欢迎各种形式的贡献,包括但不限于代码、文档、Bug 报告和功能建议。🙌\n\n## 行为准则\n\n本项目遵循 [Contributor Covenant 行为准则](./CODE_OF_CONDUCT"
},
{
"path": "Dockerfile",
"chars": 1249,
"preview": "# 编译层\nFROM node:22-alpine AS build-env\n\n# 安装 Yarn (pin a specific Yarn version)\nRUN corepack enable\nRUN corepack prepare"
},
{
"path": "LICENSE",
"chars": 1061,
"preview": "MIT License\n\nCopyright (c) 2024 Jock\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof th"
},
{
"path": "README.md",
"chars": 2548,
"preview": "<p align=\"center\">\n <img src=\"./assets/logo.svg\" alt=\"Logo\">\n</p>\n\n# wechat-article-exporter\n\n![GitHub stars]\n![GitHub "
},
{
"path": "apis/index.ts",
"chars": 4492,
"preview": "import { request } from '#shared/utils/request';\nimport { ACCOUNT_LIST_PAGE_SIZE, ARTICLE_LIST_PAGE_SIZE } from '~/confi"
},
{
"path": "app.vue",
"chars": 714,
"preview": "<template>\n <div :class=\"isDev ? 'debug-screens' : ''\" class=\"flex flex-col h-screen\">\n <NuxtLayout>\n <NuxtPage"
},
{
"path": "biome.json",
"chars": 1251,
"preview": "{\n \"$schema\": \"https://biomejs.dev/schemas/2.3.8/schema.json\",\n \"vcs\": {\n \"enabled\": true,\n \"clientKind\": \"git\","
},
{
"path": "components/ButtonGroup.vue",
"chars": 590,
"preview": "<template>\n <UDropdown :items=\"items\" :popper=\"{ placement: 'bottom-start' }\">\n <slot>\n <UButton color=\"white\" "
},
{
"path": "components/Placeholder.vue",
"chars": 736,
"preview": "<template>\n <div\n class=\"relative overflow-hidden rounded border border-dashed border-gray-400 dark:border-gray-500 "
},
{
"path": "components/ProxyMetrics.vue",
"chars": 4924,
"preview": "<template>\n <div class=\"flex flex-wrap gap-x-10 gap-y-5\">\n <div\n v-for=\"account in accountMetrics\"\n :key=\""
},
{
"path": "components/RadioGroup.vue",
"chars": 763,
"preview": "<template>\n <div class=\"flex items-baseline\">\n <div class=\"space-x-2 flex text-sm\">\n <label v-for=\"option in op"
},
{
"path": "components/StorageUsage.vue",
"chars": 757,
"preview": "<script setup lang=\"ts\">\nconst usage = ref('');\n\nasync function init() {\n const storageUsage = await navigator.storage."
},
{
"path": "components/api/CodeSegment.vue",
"chars": 1489,
"preview": "<script setup lang=\"ts\">\nimport hljs from 'highlight.js/lib/core';\nimport json from 'highlight.js/lib/languages/json';\ni"
},
{
"path": "components/api/DebugModal.vue",
"chars": 5972,
"preview": "<script setup lang=\"ts\">\nimport type { FormError } from '#ui/types';\nimport CodeSegment from '~/components/api/CodeSegme"
},
{
"path": "components/api/Document.vue",
"chars": 2772,
"preview": "<script setup lang=\"ts\">\nimport CodeSegment from '~/components/api/CodeSegment.vue';\n\ninterface TParam {\n name: string;"
},
{
"path": "components/api/Summary.vue",
"chars": 2573,
"preview": "<script setup lang=\"ts\">\nimport { sleep } from '#shared/utils/helpers';\nimport { request } from '#shared/utils/request';"
},
{
"path": "components/base/DatePicker.vue",
"chars": 1082,
"preview": "<script setup lang=\"ts\">\nimport { DatePicker as VCalendarDatePicker } from 'v-calendar';\nimport 'v-calendar/dist/style.c"
},
{
"path": "components/base/ExternalLink.vue",
"chars": 234,
"preview": "<script setup lang=\"ts\">\ninterface Props {\n href: string;\n text: string;\n}\n\ndefineProps<Props>();\n</script>\n\n<template"
},
{
"path": "components/base/Tag.vue",
"chars": 912,
"preview": "<script setup lang=\"ts\">\ninterface Props {\n label?: string;\n color: 'green' | 'red' | 'rose' | 'blue';\n}\n\nconst props "
},
{
"path": "components/dashboard/Actions.vue",
"chars": 3228,
"preview": "<script setup lang=\"ts\">\nimport type { ChipColor } from '#ui/types';\nimport CredentialsDialog, { type CredentialState } "
},
{
"path": "components/dashboard/AuthPopoverPanel.vue",
"chars": 3311,
"preview": "<script setup lang=\"ts\">\nconst { loggedIn, user, clear, openInPopup, session } = useUserSession();\n\nconst open = defineM"
},
{
"path": "components/dashboard/BottomPanel.vue",
"chars": 4206,
"preview": "<script setup lang=\"ts\">\nimport { formatDistance } from 'date-fns';\nimport { request } from '#shared/utils/request';\nimp"
},
{
"path": "components/dashboard/NavMenus.vue",
"chars": 1569,
"preview": "<script setup lang=\"ts\">\ninterface NavItem {\n name: string;\n icon: string;\n href: string;\n insider?: boolean;\n tags"
},
{
"path": "components/dashboard/SideBar.vue",
"chars": 640,
"preview": "<script setup lang=\"ts\">\nimport BottomPanel from '~/components/dashboard/BottomPanel.vue';\nimport NavMenus from '~/compo"
},
{
"path": "components/global/CredentialsDialog.vue",
"chars": 16496,
"preview": "<template>\n <USlideover v-model=\"open\" :ui=\"{ width: 'max-w-[500px]' }\">\n <UCard\n class=\"flex flex-col flex-1\"\n"
},
{
"path": "components/global/SearchAccountDialog.vue",
"chars": 3456,
"preview": "<template>\n <USlideover v-model=\"isOpen\" side=\"left\" :ui=\"{ overlay: { background: 'bg-zinc-400/75' } }\">\n <div\n "
},
{
"path": "components/grid/AccountActions.vue",
"chars": 1212,
"preview": "<script setup lang=\"ts\">\nimport type { ICellRendererParams } from 'ag-grid-community';\nimport { Loader } from 'lucide-vu"
},
{
"path": "components/grid/Album.vue",
"chars": 370,
"preview": "<script setup lang=\"ts\">\nimport type { ICellRendererParams } from 'ag-grid-community';\n\ninterface Props {\n params: ICel"
},
{
"path": "components/grid/ArticleActions.vue",
"chars": 1084,
"preview": "<script setup lang=\"ts\">\nimport type { ICellRendererParams } from 'ag-grid-community';\n\ninterface Props {\n params: ICel"
},
{
"path": "components/grid/BooleanCellRenderer.vue",
"chars": 493,
"preview": "<script setup lang=\"ts\">\nimport type { ICellRendererParams } from 'ag-grid-community';\nimport { Square, SquareCheckBig }"
},
{
"path": "components/grid/CoverTooltip.vue",
"chars": 287,
"preview": "<script setup lang=\"ts\">\nimport type { ITooltipParams } from 'ag-grid-community';\n\ninterface Props {\n params: ITooltipP"
},
{
"path": "components/grid/LoadProgress.vue",
"chars": 607,
"preview": "<script setup lang=\"ts\">\nimport type { ICellRendererParams } from 'ag-grid-community';\n\ninterface Props {\n params: ICel"
},
{
"path": "components/grid/Loading.vue",
"chars": 180,
"preview": "<script setup lang=\"ts\">\nimport { Loader } from 'lucide-vue-next';\n</script>\n\n<template>\n <div>\n <Loader :size=\"28\" "
},
{
"path": "components/grid/NoRows.vue",
"chars": 229,
"preview": "<script setup lang=\"ts\"></script>\n\n<template>\n <p class=\"flex flex-col items-center text-lg gap-2\">\n <UIcon name=\"i-"
},
{
"path": "components/grid/StatusBar.vue",
"chars": 1082,
"preview": "<script setup lang=\"ts\">\nimport type { IStatusPanelParams } from 'ag-grid-community';\n\ninterface Props {\n params: IStat"
},
{
"path": "components/modal/Confirm.vue",
"chars": 998,
"preview": "<script setup lang=\"ts\">\ndefineProps({\n icon: {\n type: String,\n default: 'i-heroicons-solid:exclamation-triangle'"
},
{
"path": "components/modal/Login.vue",
"chars": 3529,
"preview": "<script setup lang=\"ts\">\nimport { request } from '#shared/utils/request';\nimport type { LoginAccount, ScanLoginResult, S"
},
{
"path": "components/modal/QQGroup.vue",
"chars": 750,
"preview": "<script setup lang=\"ts\">\nimport qqGroupImg from '~/assets/qq-group.png';\n\nconst modal = useModal();\n\nfunction onClose() "
},
{
"path": "components/preview/Article.vue",
"chars": 13830,
"preview": "<template>\n <div>\n <USlideover v-model=\"isOpen\" :ui=\"{ width: 'max-w-[720px]' }\">\n <HtmlRenderer :html=\"article"
},
{
"path": "components/preview/HtmlRenderer.vue",
"chars": 797,
"preview": "<template>\n <div class=\"h-screen\">\n <UButton\n icon=\"i-lucide:x\"\n square\n variant=\"link\"\n color=\""
},
{
"path": "components/search/AccountForm.vue",
"chars": 427,
"preview": "<template>\n <form @submit.prevent=\"search\">\n <UInput\n icon=\"i-heroicons-magnifying-glass-20-solid\"\n color="
},
{
"path": "components/search/Article.vue",
"chars": 835,
"preview": "<template>\n <form @submit.prevent=\"search\">\n <UInput\n icon=\"i-heroicons-magnifying-glass-20-solid\"\n color="
},
{
"path": "components/selector/AccountSelectorForAlbum.vue",
"chars": 2480,
"preview": "<template>\n <USelectMenu\n v-model=\"selected\"\n size=\"md\"\n color=\"gray\"\n searchable\n searchable-placeholde"
},
{
"path": "components/selector/AccountSelectorForArticle.vue",
"chars": 1598,
"preview": "<template>\n <USelectMenu\n v-model=\"selected\"\n size=\"md\"\n color=\"gray\"\n searchable\n searchable-placeholde"
},
{
"path": "components/setting/Display.vue",
"chars": 310,
"preview": "<template>\n <UCard class=\"mx-4 mt-10 flex-1\">\n <template #header>\n <h3 class=\"text-2xl font-semibold\">显示</h3>\n "
},
{
"path": "components/setting/Export.vue",
"chars": 4162,
"preview": "<template>\n <UCard class=\"mx-4 mt-10 flex-1\">\n <template #header>\n <h3 class=\"text-2xl font-semibold\">导出选项</h3>"
},
{
"path": "components/setting/Misc.vue",
"chars": 4568,
"preview": "<template>\n <UCard class=\"mx-4 mt-10 flex-1\">\n <template #header>\n <h3 class=\"text-2xl font-semibold\">其他</h3>\n "
},
{
"path": "components/setting/Proxy.vue",
"chars": 2471,
"preview": "<template>\n <UCard class=\"mx-4 mt-10\">\n <template #header>\n <h3 class=\"text-2xl font-semibold\">代理节点</h3>\n "
},
{
"path": "composables/toast.ts",
"chars": 982,
"preview": "export default () => {\n const toast = useToast();\n\n function success(title: string, description: string = '') {\n to"
},
{
"path": "composables/useAccountEventBus.ts",
"chars": 425,
"preview": "import { type EventBusKey, useEventBus } from '@vueuse/core';\n\nconst accountEventKey: EventBusKey<string> = Symbol('acco"
},
{
"path": "composables/useBatchDownload.ts",
"chars": 1385,
"preview": "import { format } from 'date-fns';\nimport { saveAs } from 'file-saver';\nimport JSZip from 'jszip';\nimport type { Downloa"
},
{
"path": "composables/useDownloader.ts",
"chars": 8618,
"preview": "import { formatElapsedTime } from '#shared/utils/helpers';\nimport toastFactory from '~/composables/toast';\nimport type {"
},
{
"path": "composables/useExporter.ts",
"chars": 7644,
"preview": "import { formatElapsedTime } from '#shared/utils/helpers';\nimport toastFactory from '~/composables/toast';\nimport { Expo"
},
{
"path": "composables/useLoginAccount.ts",
"chars": 234,
"preview": "import { StorageSerializers } from '@vueuse/core';\nimport type { LoginAccount } from '~/types/types';\n\nexport default ()"
},
{
"path": "composables/useLoginCheck.ts",
"chars": 343,
"preview": "import LoginModal from '~/components/modal/Login.vue';\n\nexport default () => {\n const modal = useModal();\n const login"
},
{
"path": "composables/usePreferences.ts",
"chars": 865,
"preview": "import { StorageSerializers } from '@vueuse/core';\nimport { MP_ORIGIN_TIMESTAMP } from '~/config';\nimport type { Prefere"
},
{
"path": "composables/useSyncDeadline.ts",
"chars": 2310,
"preview": "import dayjs, { Dayjs } from 'dayjs';\nimport { MP_ORIGIN_TIMESTAMP } from '~/config';\nimport type { Preferences } from '"
},
{
"path": "config/index.ts",
"chars": 1425,
"preview": "import dayjs from 'dayjs';\n\n/**\n * 是否在开发环境\n */\nexport const isDev = process.env.NODE_ENV === 'development';\n\n/**\n * 网站标题"
},
{
"path": "config/proxy.txt",
"chars": 544,
"preview": "https://00.workers-proxy.ggff.net\nhttps://01.workers-proxy.ggff.net\nhttps://02.workers-proxy.ggff.net\nhttps://03.workers"
},
{
"path": "config/public-apis.ts",
"chars": 11836,
"preview": "// public api\nexport const apis = [\n {\n name: '根据关键字搜索公众号',\n description: '根据公众号名称或关键字查询公众号列表。',\n url: '/api/p"
},
{
"path": "config/public-proxy.ts",
"chars": 557,
"preview": "/**\n * 公共代理节点\n */\nexport const PUBLIC_PROXY_LIST: string[] = [\n ...getDomainProxyList('worker-proxy.asia'),\n ...getDom"
},
{
"path": "config/shared-grid-options.ts",
"chars": 2164,
"preview": "import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';\nimport { type GridOptions, themeQuartz } from 'ag-grid-co"
},
{
"path": "error.vue",
"chars": 4981,
"preview": "<template>\n <NuxtLayout>\n <div class=\"bg-[#f0f2f5] text-gray-900\">\n <div class=\"flex min-h-screen flex-col item"
},
{
"path": "nuxt.config.ts",
"chars": 1665,
"preview": "// https://nuxt.com/docs/api/configuration/nuxt-config\nexport default defineNuxtConfig({\n compatibilityDate: '2025-10-3"
},
{
"path": "package.json",
"chars": 2083,
"preview": "{\n \"name\": \"wechat-article-exporter\",\n \"version\": \"2.3.12\",\n \"type\": \"module\",\n \"scripts\": {\n \"debug\": \"nuxt dev "
},
{
"path": "pages/dashboard/account.vue",
"chars": 15825,
"preview": "<script setup lang=\"ts\">\nimport type {\n ColDef,\n GetRowIdParams,\n GridApi,\n GridOptions,\n GridReadyEvent,\n ICellRe"
},
{
"path": "pages/dashboard/album.vue",
"chars": 10728,
"preview": "<template>\n <div class=\"h-full\">\n <Teleport defer to=\"#title\">\n <h1 class=\"text-[28px] leading-[34px] text-slat"
},
{
"path": "pages/dashboard/api.vue",
"chars": 562,
"preview": "<template>\n <div class=\"flex flex-col h-full\">\n <Teleport defer to=\"#title\">\n <h1 class=\"text-[28px] leading-[3"
},
{
"path": "pages/dashboard/article.vue",
"chars": 18256,
"preview": "<script setup lang=\"ts\">\nimport type {\n ColDef,\n FilterChangedEvent,\n GetRowIdParams,\n GridApi,\n GridOptions,\n Gri"
},
{
"path": "pages/dashboard/proxy.vue",
"chars": 3679,
"preview": "<template>\n <div class=\"h-full\">\n <Teleport defer to=\"#title\">\n <h1 class=\"text-[28px] leading-[34px] text-slat"
},
{
"path": "pages/dashboard/settings.vue",
"chars": 623,
"preview": "<template>\n <div class=\"h-full\">\n <Teleport defer to=\"#title\">\n <h1 class=\"text-[28px] leading-[34px] text-slat"
},
{
"path": "pages/dashboard/single.vue",
"chars": 19952,
"preview": "<script setup lang=\"ts\">\nimport {\n type ColDef,\n type FilterChangedEvent,\n type GetRowIdParams,\n type GridApi,\n typ"
},
{
"path": "pages/dashboard/support.vue",
"chars": 1781,
"preview": "<template>\n <div class=\"flex flex-col h-full\">\n <Teleport defer to=\"#title\">\n <h1 class=\"text-[28px] leading-[3"
},
{
"path": "pages/dashboard.vue",
"chars": 653,
"preview": "<template>\n <div class=\"flex\">\n <!-- 左侧边栏 -->\n <SideBar />\n\n <div class=\"flex flex-col flex-1 overflow-hidden "
},
{
"path": "pages/dev/discuss.vue",
"chars": 71697,
"preview": "<template>\n <div style=\"max-width: 667px; margin: 0 auto; padding: 10px 10px 80px\">\n <p style=\"font-size: 15px; colo"
},
{
"path": "pages/dev/example-error.vue",
"chars": 561,
"preview": "<script setup>\nimport * as Sentry from '@sentry/nuxt';\nimport { request } from '#shared/utils/request.ts';\n\nfunction tri"
},
{
"path": "pages/dev/markdown.vue",
"chars": 3839,
"preview": "<template>\n <div class=\"p-2 mx-auto container h-screen\">\n <div class=\"rounded-lg border shadow-sm\">\n <div class"
},
{
"path": "pages/dev/youtube-channel-id.vue",
"chars": 1956,
"preview": "<template>\n <div class=\"p-2 mx-auto container\">\n <p class=\"flex justify-between\">\n <span>频道地址: </span>\n <U"
},
{
"path": "pages/index.vue",
"chars": 172,
"preview": "<script setup lang=\"ts\">\nnavigateTo('/dashboard/account');\n</script>\n\n<template>\n <div class=\"flex justify-center items"
},
{
"path": "public/custom-elements/mp-common-mpaudio.js",
"chars": 6741,
"preview": "function formatDuration(duration) {\n if (duration < 60) return duration + '秒';\n if (duration < 3600) return Math.floor"
},
{
"path": "public/plugins/credential.py",
"chars": 3288,
"preview": "import mitmproxy.http\nimport json\nimport threading\nfrom http.server import SimpleHTTPRequestHandler, HTTPServer\nfrom url"
},
{
"path": "public/vendors/html-docx-js@0.3.1/html-docx.js",
"chars": 416067,
"preview": "!function(e){if(\"object\"==typeof exports&&\"undefined\"!=typeof module)module.exports=e();else if(\"function\"==typeof defin"
},
{
"path": "samples/aboutbiz/biz-MjM5ODMxNzE2NQ==.html",
"chars": 27027,
"preview": "<!-- 注意:这个文件是一个公共文件,被很多地方引用,改动需要注意其他类型页面是否有受影响 -->\n<!DOCTYPE html>\n<html class=\"\">\n <head>\n <m"
},
{
"path": "samples/aboutbiz/biz-MjM5OTM0OTE1Mg==.html",
"chars": 27948,
"preview": "<!-- 注意:这个文件是一个公共文件,被很多地方引用,改动需要注意其他类型页面是否有受影响 -->\n<!DOCTYPE html>\n<html class=\"\">\n <head>\n <m"
},
{
"path": "samples/aboutbiz/biz-MzAxODU1ODg2Mg==.html",
"chars": 24275,
"preview": "<!-- 注意:这个文件是一个公共文件,被很多地方引用,改动需要注意其他类型页面是否有受影响 -->\n<!DOCTYPE html>\n<html class=\"\">\n <head>\n <m"
},
{
"path": "samples/aboutbiz/biz-MzI0Nzg2MTExMA==.html",
"chars": 35361,
"preview": "<!-- 注意:这个文件是一个公共文件,被很多地方引用,改动需要注意其他类型页面是否有受影响 -->\n<!DOCTYPE html>\n<html class=\"\">\n <head>\n <m"
},
{
"path": "samples/aboutbiz/biz-MzI4NjAxNjY4Nw==.html",
"chars": 27523,
"preview": "<!-- 注意:这个文件是一个公共文件,被很多地方引用,改动需要注意其他类型页面是否有受影响 -->\n<!DOCTYPE html>\n<html class=\"\">\n <head>\n <m"
},
{
"path": "samples/aboutbiz/biz-MzIxMTgzNjE5MA==.html",
"chars": 25569,
"preview": "<!-- 注意:这个文件是一个公共文件,被很多地方引用,改动需要注意其他类型页面是否有受影响 -->\n<!DOCTYPE html>\n<html class=\"\">\n <head>\n <m"
},
{
"path": "samples/aboutbiz/biz-Mzg3OTYzMDkzMg==.html",
"chars": 28968,
"preview": "<!-- 注意:这个文件是一个公共文件,被很多地方引用,改动需要注意其他类型页面是否有受影响 -->\n<!DOCTYPE html>\n<html class=\"\">\n <head>\n <m"
},
{
"path": "samples/author/01.html",
"chars": 3266830,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/author/包含author信息.md",
"chars": 266,
"preview": "# 包含 author 信息文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章类型 | 文章标题 |\n|----|------|-----"
},
{
"path": "samples/作者已删除/01.html",
"chars": 37550,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/作者已删除/02.html",
"chars": 37550,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/作者已删除/03.html",
"chars": 37550,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/作者已删除/04.html",
"chars": 37550,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/作者已删除/05.html",
"chars": 37550,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/作者已删除/06.html",
"chars": 38770,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/作者已删除/作者已删除.md",
"chars": 790,
"preview": "# 作者已删除文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章类型 | 文章标题 "
},
{
"path": "samples/内容违规/01.html",
"chars": 38211,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/内容违规/02.html",
"chars": 38425,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/内容违规/内容违规.md",
"chars": 364,
"preview": "# 内容违规文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章类型 | 文章标题 |\n|----|---------|----"
},
{
"path": "samples/图片分享/01.html",
"chars": 2366874,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/图片分享/02.html",
"chars": 2315026,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/图片分享/03.html",
"chars": 2306249,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/图片分享/04.html",
"chars": 2313170,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/图片分享/05.html",
"chars": 2288126,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/图片分享/图片分享.md",
"chars": 803,
"preview": "# 图片分享文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章标题 "
},
{
"path": "samples/文本分享/01.html",
"chars": 2558541,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/文本分享/02.html",
"chars": 2553731,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/文本分享/03.html",
"chars": 2604183,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/文本分享/04.html",
"chars": 2596411,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/文本分享/c01.html",
"chars": 2400313,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/文本分享/c02.html",
"chars": 2402017,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/文本分享/c03.html",
"chars": 2402077,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/文本分享/c04.html",
"chars": 2403219,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/文本分享/c05.html",
"chars": 2401248,
"preview": "<!DOCTYPE html>\n<html class=\"\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv="
},
{
"path": "samples/文本分享/文本分享.md",
"chars": 969,
"preview": "# 文本分享文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章标题 |\n|-----|----------|---"
},
{
"path": "samples/文章分享/01.html",
"chars": 3161781,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/文章分享/02.html",
"chars": 3032483,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/文章分享/03.html",
"chars": 3649981,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/文章分享/04.html",
"chars": 3699098,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/文章分享/文章分享.md",
"chars": 618,
"preview": "# 文章分享文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章标题 |\n|----|-"
},
{
"path": "samples/普通图文/01.html",
"chars": 3043429,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/普通图文/02.html",
"chars": 3552147,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/普通图文/03.html",
"chars": 3022871,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/普通图文/04.html",
"chars": 2899919,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equi"
},
{
"path": "samples/普通图文/c01.html",
"chars": 2867969,
"preview": "<!DOCTYPE html>\n<html class=\"\n\">\n<head>\n <meta name=\"wechat-enable-text-zoom-em\" content=\"true\">\n <meta http-equiv"
},
{
"path": "samples/普通图文/普通图文.md",
"chars": 720,
"preview": "# 普通图文文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章标题 "
},
{
"path": "samples/该内容暂时无法查看/01.html",
"chars": 898,
"preview": "<!DOCTYPE html>\n<html>\n<head>\n <meta http-equiv=\"content-type\" content=\"text/html;charset=utf8\">\n <meta id=\"viewpo"
},
{
"path": "samples/该内容暂时无法查看/无法查看.md",
"chars": 228,
"preview": "# 无法查看文章样例\n\n| 编号 | 公众号 | 文章链接 | 文章标题 |\n|----|-----|------------------------"
},
{
"path": "sentry.client.config.ts",
"chars": 1501,
"preview": "import * as Sentry from '@sentry/nuxt';\nimport { useRuntimeConfig } from '#imports';\n\nconst dsn = useRuntimeConfig().pub"
},
{
"path": "server/api/_debug.get.ts",
"chars": 328,
"preview": "import { cookieStore } from '~/server/utils/CookieStore';\n\ninterface DebugQuery {\n key: string;\n}\n\nexport default defin"
},
{
"path": "server/api/public/beta/aboutbiz.get.ts",
"chars": 3502,
"preview": "import fs from 'node:fs';\nimport * as cheerio from 'cheerio';\nimport { isDev } from '~/config';\n\ninterface AboutBizQuery"
},
{
"path": "server/api/public/beta/authorinfo.get.ts",
"chars": 672,
"preview": "/**\n * 搜索公众号主体信息\n */\n\nimport { proxyMpRequest } from '~/server/utils/proxy-request';\n\ninterface AuthorInfoQuery {\n fake"
},
{
"path": "server/api/public/v1/account.get.ts",
"chars": 1214,
"preview": "import { getTokenFromStore } from '~/server/utils/CookieStore';\nimport { proxyMpRequest } from '~/server/utils/proxy-req"
},
{
"path": "server/api/public/v1/accountbyurl.get.ts",
"chars": 397,
"preview": "import { request } from '#shared/utils/request';\n\ninterface UrlQuery {\n url: string;\n}\n\nexport default defineEventHandl"
},
{
"path": "server/api/public/v1/article.get.ts",
"chars": 1902,
"preview": "import { getTokenFromStore } from '~/server/utils/CookieStore';\nimport { proxyMpRequest } from '~/server/utils/proxy-req"
},
{
"path": "server/api/public/v1/authkey.get.ts",
"chars": 474,
"preview": "import { getMpCookie } from '~/server/kv/cookie';\nimport { getAuthKeyFromRequest } from '~/server/utils/proxy-request';\n"
},
{
"path": "server/api/public/v1/download.get.ts",
"chars": 1876,
"preview": "import TurndownService from 'turndown';\nimport { urlIsValidMpArticle } from '#shared/utils';\nimport { normalizeHtml, par"
},
{
"path": "server/api/web/login/bizlogin.post.ts",
"chars": 1655,
"preview": "import dayjs from 'dayjs';\nimport { request } from '#shared/utils/request';\nimport { getCookieFromResponse, getCookiesFr"
},
{
"path": "server/api/web/login/getqrcode.get.ts",
"chars": 473,
"preview": "import { getCookiesFromRequest } from '~/server/utils/CookieStore';\nimport { proxyMpRequest } from '~/server/utils/proxy"
},
{
"path": "server/api/web/login/scan.get.ts",
"chars": 501,
"preview": "import { getCookiesFromRequest } from '~/server/utils/CookieStore';\nimport { proxyMpRequest } from '~/server/utils/proxy"
},
{
"path": "server/api/web/login/session/[sid].post.ts",
"chars": 639,
"preview": "import { proxyMpRequest } from '~/server/utils/proxy-request';\n\nexport default defineEventHandler(async event => {\n con"
},
{
"path": "server/api/web/misc/accountname.get.ts",
"chars": 1149,
"preview": "import * as cheerio from 'cheerio';\nimport { USER_AGENT } from '~/config';\n\ninterface AccountNameQuery {\n url: string;\n"
},
{
"path": "server/api/web/misc/appmsgalbum.get.ts",
"chars": 1211,
"preview": "/**\n * 获取合集数据接口\n */\n\nimport { proxyMpRequest } from '~/server/utils/proxy-request';\n\ninterface AppMsgAlbumQuery {\n fake"
},
{
"path": "server/api/web/misc/comment.get.ts",
"chars": 868,
"preview": "/**\n * 获取文章评论\n */\n\nimport { proxyMpRequest } from '~/server/utils/proxy-request';\n\ninterface GetCommentQuery {\n __biz: "
},
{
"path": "server/api/web/misc/current-ip.get.ts",
"chars": 834,
"preview": "/**\n * 查询当前ip\n */\nimport { H3Event } from 'h3';\n\nexport default defineEventHandler(async event => {\n // 查询用户的当前ip并返回\n "
},
{
"path": "server/api/web/mp/appmsgpublish.get.ts",
"chars": 1337,
"preview": "/**\n * 获取文章列表接口\n */\n\nimport { getTokenFromStore } from '~/server/utils/CookieStore';\nimport { proxyMpRequest } from '~/s"
},
{
"path": "server/api/web/mp/info.get.ts",
"chars": 1272,
"preview": "/**\n * 获取登录用户信息接口\n *\n * 备注:\n * 这个接口用于后端登录成功之后调用,非客户端直接调用\n */\n\nimport { getTokenFromStore } from '~/server/utils/CookieSt"
},
{
"path": "server/api/web/mp/logout.get.ts",
"chars": 885,
"preview": "/**\n * 退出登录接口\n */\n\nimport { parseCookies } from 'h3';\nimport { cookieStore, getTokenFromStore } from '~/server/utils/Coo"
},
{
"path": "server/api/web/mp/profile_ext_getmsg.get.ts",
"chars": 953,
"preview": "/**\n * 搜索公众号文章列表接口\n */\n\nimport { proxyMpRequest } from '~/server/utils/proxy-request';\n\ninterface SearchBizQuery {\n beg"
},
{
"path": "server/api/web/mp/searchbiz.get.ts",
"chars": 1082,
"preview": "/**\n * 搜索公众号接口\n */\n\nimport { getTokenFromStore } from '~/server/utils/CookieStore';\nimport { proxyMpRequest } from '~/se"
},
{
"path": "server/api/web/mp/searchbyurl.get.ts",
"chars": 1038,
"preview": "import { request } from '#shared/utils/request';\n\ninterface UrlQuery {\n url: string;\n}\n\nexport default defineEventHandl"
},
{
"path": "server/api/web/worker/README.md",
"chars": 109,
"preview": "# 接口说明\n\n该目录下的接口数据由托管在 Deno Deploy 上面的 [my-cron-service](https://dash.deno.com/projects/my-cron-service) 项目提供\n"
},
{
"path": "server/api/web/worker/blocked-ip-list.get.ts",
"chars": 324,
"preview": "/**\n * 查询 ip 黑名单\n */\nimport { EXTERNAL_API_SERVICE } from '~/config';\nimport { fetchExternal } from '~/server/utils/fetc"
},
{
"path": "server/api/web/worker/overview-metrics.get.ts",
"chars": 330,
"preview": "/**\n * 查询公共代理状态\n */\nimport { EXTERNAL_API_SERVICE } from '~/config';\nimport { fetchExternal } from '~/server/utils/fetch"
},
{
"path": "server/api/web/worker/security-top-n.get.ts",
"chars": 430,
"preview": "/**\n * 查询公共代理状态\n */\nimport { EXTERNAL_API_SERVICE } from '~/config';\nimport { fetchExternal } from '~/server/utils/fetch"
},
{
"path": "server/kv/cookie.ts",
"chars": 793,
"preview": "import { type CookieEntity } from '~/server/utils/CookieStore';\n\nexport type CookieKVKey = string;\n\nexport interface Coo"
},
{
"path": "server/tsconfig.json",
"chars": 49,
"preview": "{\n \"extends\": \"../.nuxt/tsconfig.server.json\"\n}\n"
},
{
"path": "server/types.d.ts",
"chars": 647,
"preview": "import { H3Event } from 'h3';\n\ntype Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n\nexport interface RequestOptio"
},
{
"path": "server/utils/CookieStore.ts",
"chars": 7902,
"preview": "import { H3Event, parseCookies } from 'h3';\nimport { CookieKVValue, getMpCookie, setMpCookie } from '~/server/kv/cookie'"
},
{
"path": "server/utils/fetch_external.ts",
"chars": 495,
"preview": "interface FetchExternalOption {\n label: string;\n default: any;\n}\n\nexport async function fetchExternal(url: string, opt"
},
{
"path": "server/utils/logger.ts",
"chars": 1788,
"preview": "import fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = pat"
},
{
"path": "server/utils/proxy-request.ts",
"chars": 4813,
"preview": "import dayjs from 'dayjs';\nimport { H3Event, parseCookies } from 'h3';\nimport { v4 as uuidv4 } from 'uuid';\nimport { isD"
},
{
"path": "shared/readme.md",
"chars": 56,
"preview": "# shared 目录说明\n\n该目录用于存放 **客户端** 和 **服务端** 都通用的工具函数和类型定义。\n"
},
{
"path": "shared/utils/helpers.ts",
"chars": 1643,
"preview": "import dayjs from 'dayjs';\nimport { ITEM_SHOW_TYPE } from '~/config';\n\nexport function sleep(ms: number = 1000): Promise"
},
{
"path": "shared/utils/html.ts",
"chars": 6882,
"preview": "import * as cheerio from 'cheerio';\nimport { EXTERNAL_API_SERVICE } from '~/config';\nimport { extractCommentId } from '~"
},
{
"path": "shared/utils/index.ts",
"chars": 201,
"preview": "/**\n * url是否是合法的微信公众号文章url\n * @param url\n */\nexport function urlIsValidMpArticle(url: string) {\n try {\n return new U"
},
{
"path": "shared/utils/renderer.ts",
"chars": 14282,
"preview": "import * as cheerio from 'cheerio';\nimport { getMetadataCache } from '~/store/v2/metadata';\nimport { renderComments } fr"
},
{
"path": "shared/utils/request.ts",
"chars": 262,
"preview": "/**\n * 封装 $fetch 无重试请求\n */\nexport const request = $fetch.create({\n retry: 0,\n method: 'GET',\n async onResponse({ requ"
},
{
"path": "store/v2/article.ts",
"chars": 3979,
"preview": "import type { AppMsgExWithFakeID, PublishInfo, PublishPage } from '~/types/types';\nimport { db } from './db';\nimport { t"
},
{
"path": "store/v2/assets.ts",
"chars": 518,
"preview": "import { db } from './db';\n\ninterface Asset {\n url: string;\n file: Blob;\n fakeid: string;\n}\n\nexport type { Asset };\n\n"
},
{
"path": "store/v2/comment.ts",
"chars": 531,
"preview": "import { db } from './db';\n\nexport interface CommentAsset {\n fakeid: string;\n url: string;\n title: string;\n data: an"
},
{
"path": "store/v2/comment_reply.ts",
"chars": 681,
"preview": "import { db } from './db';\n\nexport interface CommentReplyAsset {\n fakeid: string;\n url: string;\n title: string;\n dat"
},
{
"path": "store/v2/db.ts",
"chars": 1659,
"preview": "import Dexie, { type EntityTable, type Table } from 'dexie';\nimport type { ArticleAsset } from './article';\nimport type "
},
{
"path": "store/v2/debug.ts",
"chars": 612,
"preview": "import { db } from './db';\n\nexport interface DebugAsset {\n type: string;\n url: string;\n file: Blob;\n title: string;\n"
},
{
"path": "store/v2/html.ts",
"chars": 522,
"preview": "import { db } from './db';\n\nexport interface HtmlAsset {\n fakeid: string;\n url: string;\n file: Blob;\n title: string;"
},
{
"path": "store/v2/index.ts",
"chars": 1078,
"preview": "import { db } from './db';\n\n// 删除公众号数据\nexport async function deleteAccountData(ids: string[]): Promise<void> {\n return "
},
{
"path": "store/v2/info.ts",
"chars": 2706,
"preview": "import { db } from './db';\n\nexport interface MpAccount {\n fakeid: string;\n completed: boolean;\n count: number;\n arti"
},
{
"path": "store/v2/metadata.ts",
"chars": 586,
"preview": "import type { ArticleMetadata } from '~/utils/download/types';\nimport { db } from './db';\n\nexport type Metadata = Articl"
},
{
"path": "store/v2/resource-map.ts",
"chars": 587,
"preview": "import { db } from './db';\n\nexport interface ResourceMapAsset {\n fakeid: string;\n url: string;\n resources: string[];\n"
},
{
"path": "store/v2/resource.ts",
"chars": 528,
"preview": "import { db } from './db';\n\nexport interface ResourceAsset {\n fakeid: string;\n url: string;\n file: Blob;\n}\n\n/**\n * 更新"
},
{
"path": "style.css",
"chars": 791,
"preview": ":root {\n /*--ag-selected-row-background-color: transparent;*/\n\n .ag-header-cell-label {\n justify-content: c"
},
{
"path": "tailwind.config.js",
"chars": 771,
"preview": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n content: [],\n theme: {\n extend: {\n colors: {\n "
},
{
"path": "test/common.ts",
"chars": 2649,
"preview": "import fs from 'node:fs';\nimport path from 'node:path';\n\nconst samplesDirectory = path.join(__dirname, '../samples');\n\ni"
},
{
"path": "test/normalize_html.ts",
"chars": 701,
"preview": "import { normalizeHtml } from '#shared/utils/html';\nimport { read, samples, write } from './common';\n\nfunction normalize"
},
{
"path": "test/parse_cgi_data.ts",
"chars": 891,
"preview": "import { parseCgiDataNew } from '#shared/utils/html';\nimport { read, samples, write } from './common';\n\nfunction normali"
},
{
"path": "test/render_html_from_cgi_data.ts",
"chars": 1046,
"preview": "import { parseCgiDataNew } from '#shared/utils/html';\nimport { renderHTMLFromCgiDataNew } from '#shared/utils/renderer';"
},
{
"path": "test/validate_html_content.ts",
"chars": 403,
"preview": "import { validateHTMLContent } from '#shared/utils/html';\nimport { read, samples } from './common';\n\nfunction run() {\n "
},
{
"path": "todos.md",
"chars": 111,
"preview": "# TODOs\n\n- 优化文章类型\n- 完善导出格式(md/word/pdf)\n- 集成数据库\n\n## 可疑 ip\n\n```text\n240e:345:360a:b200:d4ac:d44d:21cf:6ef0\n\n```\n"
},
{
"path": "tsconfig.json",
"chars": 94,
"preview": "{\n // https://nuxt.com/docs/guide/concepts/typescript\n \"extends\": \"./.nuxt/tsconfig.json\"\n}\n"
},
{
"path": "types/account.d.ts",
"chars": 169,
"preview": "import type { MpAccount } from '~/store/v2/info';\n\nexport interface AccountManifest {\n version: string;\n usefor: 'wech"
},
{
"path": "types/album.d.ts",
"chars": 1353,
"preview": "import type { RGB } from '~/types/types';\n\ninterface BaseResp {\n exportkey_token: string;\n ret: number;\n}\ntype Boolean"
}
]
// ... and 22 more files (download for full content)
About this extraction
This page contains the full source code of the wechat-article/wechat-article-exporter GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 222 files (64.5 MB), approximately 16.9M tokens, and a symbol index with 571 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.