Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ samples/**/output

# wrangler
.wrangler
.omx/
.omc/
26 changes: 26 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Repository Guidelines

## Project Structure & Module Organization
This repository is a Nuxt 3 application with client pages in `pages/`, reusable UI in `components/`, and stateful client logic in `composables/` and `store/v2/`. Shared parsing and rendering code lives in `shared/utils/` and `utils/`. Server endpoints and helpers are under `server/api/`, `server/utils/`, and `server/kv/`. Static assets belong in `public/` and `assets/`. Sample WeChat HTML fixtures used for regression checks are stored in `samples/`, and ad hoc validation scripts live in `test/`.

## Build, Test, and Development Commands
Use Node `>=22` and Yarn `1.22.22`.

- `corepack enable && corepack prepare yarn@1.22.22 --activate && yarn`: install dependencies.
- `yarn dev`: start the local Nuxt development server.
- `yarn build`: produce a production build in `.output/`.
- `yarn preview`: build for the Cloudflare Pages target and run a local preview.
- `yarn format`: run Biome formatting and import organization.
- `yarn docker:build`: build the published container image.

## Coding Style & Naming Conventions
Biome is the formatting source of truth; use `yarn format` before opening a PR. The codebase uses 2-space indentation, semicolons, single quotes in JS/TS, and a 120-column target. Keep Vue components in PascalCase such as `components/dashboard/SideBar.vue`, composables in `useX.ts` form, and route files lowercase such as `pages/dashboard/article.vue`. Place shared type declarations in `types/*.d.ts` or `server/types.d.ts`. Avoid editing generated vendor assets in `public/vendors/` unless you are intentionally updating a bundled dependency.

## Testing Guidelines
There is currently no unified `yarn test` script. For parser, renderer, and export changes, add or update a focused script in `test/` and validate against the HTML fixtures in `samples/`. Follow the existing script naming style, for example `test/normalize_html.ts` and `test/render_html_from_cgi_data.ts`. In every PR, state the exact validation command or manual flow you ran.

## Commit & Pull Request Guidelines
Recent history uses short version bumps plus concise `feat:` and `fix:` subjects. Prefer descriptive commit messages such as `fix: handle empty CGI payload in exporter` and keep each commit narrowly scoped. PRs should explain the user-visible change, link the relevant issue when available, note any config or deployment impact, and include screenshots for UI changes. Call out test coverage and known gaps explicitly.

## Security & Configuration Tips
Do not commit live WeChat credentials, exported article data, or local OMX state. Runtime configuration is environment-driven; common variables include `NUXT_AGGRID_LICENSE`, `NUXT_SENTRY_*`, `NUXT_UMAMI_*`, and Nitro KV settings.
24 changes: 12 additions & 12 deletions components/api/Summary.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import type { GetAuthKeyResult } from '~/types/types';
const toast = toastFactory();

const loading = ref(false);
const authKey = ref('');
async function getAuthKey() {
const apiKey = ref('');
async function getApiKey() {
loading.value = true;
try {
await sleep(1000);
const resp = await request<GetAuthKeyResult>(`/api/public/v1/authkey`);
if (resp.code === 0) {
authKey.value = resp.data;
apiKey.value = resp.data;
} else {
toast.error('获取密钥失败', resp.msg);
toast.error('获取 API 密钥失败', resp.msg);
}
} finally {
loading.value = false;
Expand Down Expand Up @@ -53,14 +53,14 @@ async function getAuthKey() {
<!-- </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>
<p>以下所有 <code>API</code> 如无特殊说明,均需要携带密钥进行调用。推荐通过以下方式传输:</p>
<p>a. 通过自定义请求头 <code class="text-rose-500 font-medium font-mono">X-Auth-Key</code> 传递 API 密钥</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
>调用 API 的密钥由当前登录态签发,你在该网站扫码登录并保持会话有效时,可以随时重新生成或查询当前 API 密钥。</span
>
</p>
</li>
Expand All @@ -70,12 +70,12 @@ async function getAuthKey() {
</p>
</li>
</ol>
<UButton class="mt-3" color="blue" :loading="loading" @click="getAuthKey">
<UButton class="mt-3" color="blue" :loading="loading" @click="getApiKey">
查询 API 密钥 (确保当前登录信息有效)
</UButton>
<div v-if="authKey">
<p class="mt-5 mb-2">当前密钥:</p>
<CodeSegment :code="authKey" lang="text" class="max-w-xl" />
<div v-if="apiKey">
<p class="mt-5 mb-2">当前 API 密钥:</p>
<CodeSegment :code="apiKey" lang="text" class="max-w-xl" />
</div>
</template>
</UAlert>
Expand Down
2 changes: 2 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default defineNuxtConfig({
client: 'hidden',
},
nitro: {
preset: process.env.NITRO_PRESET,
minify: process.env.NODE_ENV === 'production',
rollupConfig: {
external: ['puppeteer'],
Expand All @@ -43,6 +44,7 @@ export default defineNuxtConfig({
kv: {
driver: process.env.NITRO_KV_DRIVER || 'memory',
base: process.env.NITRO_KV_BASE,
binding: 'KV',
},
},
},
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"debug": "nuxt dev --inspect",
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "NITRO_PRESET=cloudflare_pages nuxt build && npx wrangler --cwd dist pages dev",
"preview": "NITRO_PRESET=cloudflare_pages NITRO_KV_DRIVER=cloudflare-kv-binding nuxt build && npx wrangler pages dev dist",
"deploy": "NITRO_PRESET=cloudflare_pages NITRO_KV_DRIVER=cloudflare-kv-binding nuxt build && wrangler pages deploy dist --project-name wechat-article-exporter --commit-dirty=true",
"test:api-core": "vite-node --script test/api-core/session-core.test.ts && vite-node --script test/api-core/mp-core.test.ts && vite-node --script test/api-core/auth-token-lifecycle.test.ts",
"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 .",
Expand Down
22 changes: 12 additions & 10 deletions server/api/_debug.get.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { cookieStore } from '~/server/utils/CookieStore';

interface DebugQuery {
key: string;
}
import { getSessionCacheSnapshot } from '~/server/services/api/auth-session';

export default defineEventHandler(async event => {
const { key } = getQuery<DebugQuery>(event);
if (key && key === process.env.DEBUG_KEY) {
return cookieStore.toJSON();
} else {
return 'not set debug key';
if (process.env.NODE_ENV !== 'development') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
}

const debugKey = getRequestHeader(event, 'x-debug-key');
if (!debugKey || debugKey !== process.env.DEBUG_KEY) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
}

return {
activeSessions: Object.keys(getSessionCacheSnapshot()).length,
};
});
22 changes: 3 additions & 19 deletions server/api/public/beta/authorinfo.get.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
/**
* 搜索公众号主体信息
*/

import { proxyMpRequest } from '~/server/utils/proxy-request';
import { fetchAuthorInfoResponse } from '~/server/services/api/mp-service';

interface AuthorInfoQuery {
fakeid: string;
Expand All @@ -11,20 +7,8 @@ interface AuthorInfoQuery {
export default defineEventHandler(async event => {
const { fakeid } = getQuery<AuthorInfoQuery>(event);

const params: Record<string, string | number> = {
wxtoken: '777',
biz: fakeid,
__biz: fakeid,
x5: 0,
f: 'json',
};

return proxyMpRequest({
event: event,
method: 'GET',
endpoint: 'https://mp.weixin.qq.com/mp/authorinfo',
query: params,
parseJson: true,
return fetchAuthorInfoResponse(event, {
fakeid,
}).catch(e => {
return {
base_resp: {
Expand Down
28 changes: 8 additions & 20 deletions server/api/public/v1/account.get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getTokenFromStore } from '~/server/utils/CookieStore';
import { proxyMpRequest } from '~/server/utils/proxy-request';
import { getTokenFromEvent } from '~/server/services/api/auth-session';
import { fetchSearchBizResponse } from '~/server/services/api/mp-service';

interface SearchBizQuery {
begin?: number;
Expand All @@ -8,7 +8,7 @@ interface SearchBizQuery {
}

export default defineEventHandler(async event => {
const token = await getTokenFromStore(event);
const token = await getTokenFromEvent(event);

if (!token) {
return {
Expand All @@ -33,23 +33,11 @@ export default defineEventHandler(async event => {
const begin: number = query.begin || 0;
const size: number = query.size || 5;

const params: Record<string, string | number> = {
action: 'search_biz',
begin: begin,
count: size,
query: keyword,
token: token,
lang: 'zh_CN',
f: 'json',
ajax: '1',
};

return proxyMpRequest({
event: event,
method: 'GET',
endpoint: 'https://mp.weixin.qq.com/cgi-bin/searchbiz',
query: params,
parseJson: true,
return fetchSearchBizResponse(event, {
token,
keyword,
begin,
size,
}).catch(e => {
return {
base_resp: {
Expand Down
11 changes: 5 additions & 6 deletions server/api/public/v1/accountbyurl.get.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { request } from '#shared/utils/request';
import { searchAccountByArticleUrl } from '~/server/services/api/mp-service';

interface UrlQuery {
url: string;
Expand All @@ -7,10 +7,9 @@ interface UrlQuery {
export default defineEventHandler(async event => {
const { url } = getQuery<UrlQuery>(event);

return await request('/api/web/mp/searchbyurl?url=' + encodeURIComponent(url), {
headers: {
'X-Auth-Key': getHeader(event, 'X-Auth-Key')!,
Cookie: getHeader(event, 'Cookie')!,
},
return searchAccountByArticleUrl(event, {
url,
authErrorMessage: '认证信息无效',
searchErrorMessage: '搜索公众号接口失败,请稍后重试',
});
});
45 changes: 11 additions & 34 deletions server/api/public/v1/article.get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getTokenFromStore } from '~/server/utils/CookieStore';
import { proxyMpRequest } from '~/server/utils/proxy-request';
import { getTokenFromEvent } from '~/server/services/api/auth-session';
import { extractPublishedArticles } from '~/server/services/api/mp-core';
import { fetchAppMsgPublishResponse } from '~/server/services/api/mp-service';

interface AppMsgPublishQuery {
fakeid: string;
Expand All @@ -9,7 +10,7 @@ interface AppMsgPublishQuery {
}

export default defineEventHandler(async event => {
const token = await getTokenFromStore(event);
const token = await getTokenFromEvent(event);

if (!token) {
return {
Expand All @@ -34,30 +35,12 @@ export default defineEventHandler(async event => {
const begin: number = query.begin || 0;
const size: number = query.size || 5;

const isSearching = !!keyword;

const params: Record<string, string | number> = {
sub: isSearching ? 'search' : 'list',
search_field: isSearching ? '7' : 'null',
begin: begin,
count: size,
query: keyword,
fakeid: fakeid,
type: '101_1',
free_publish_type: 1,
sub_action: 'list_ex',
token: token,
lang: 'zh_CN',
f: 'json',
ajax: 1,
};

const resp = await proxyMpRequest({
event: event,
method: 'GET',
endpoint: 'https://mp.weixin.qq.com/cgi-bin/appmsgpublish',
query: params,
parseJson: true,
const resp = await fetchAppMsgPublishResponse(event, {
token,
fakeid,
keyword,
begin,
size,
}).catch(e => {
return {
base_resp: {
Expand All @@ -68,13 +51,7 @@ export default defineEventHandler(async event => {
});

if (resp.base_resp.ret === 0) {
const publish_page = JSON.parse(resp.publish_page);
const articles = publish_page.publish_list
.filter((item: any) => !!item.publish_info)
.flatMap((item: any) => {
const publish_info = JSON.parse(item.publish_info);
return publish_info.appmsgex;
});
const articles = extractPublishedArticles(resp);
return {
base_resp: resp.base_resp,
articles: articles,
Expand Down
23 changes: 16 additions & 7 deletions server/api/public/v1/authkey.get.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { getMpCookie } from '~/server/kv/cookie';
import { getAuthKeyFromRequest } from '~/server/utils/proxy-request';
import {
getSessionByAuthKey,
issueApiTokenForAuthKey,
resolveAuthKeyFromEvent,
} from '~/server/services/api/auth-session';

export default defineEventHandler(async event => {
const authKey = getAuthKeyFromRequest(event);
const authKey = await resolveAuthKeyFromEvent(event);
const session = await getSessionByAuthKey(authKey);

// 这里进行服务器验证,确定请求中的 auth-key 是否还有效
const cookie = await getMpCookie(authKey);
if (authKey && session) {
const apiToken = await issueApiTokenForAuthKey(authKey);
if (!apiToken) {
return {
code: -1,
msg: 'API token issue failed',
};
}

if (authKey && cookie) {
return {
code: 0,
data: authKey,
data: apiToken,
};
} else {
return {
Expand Down
Loading