Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7ae2fbfcb | ||
|
|
b1bb28f5aa | ||
|
|
766c6a624f | ||
|
|
3c672f8355 | ||
|
|
b75755e6a6 | ||
|
|
ff2755e218 | ||
|
|
58f5de03f2 | ||
|
|
12ff0b56fb | ||
|
|
2c7ea3cc45 | ||
|
|
668aec45c5 | ||
|
|
cbcc06380d | ||
|
|
e8f68e30e5 | ||
|
|
18a6a18426 | ||
|
|
968cc88c62 |
@@ -21,16 +21,65 @@ npm run build
|
||||
## Current Scope
|
||||
|
||||
- Adds two after-search-rate columns to the Xingtu market list
|
||||
- Adds a popup-based Logto auth entry
|
||||
- Hydrates the current page immediately
|
||||
- Provides plugin-owned filter, sort, and CSV export controls
|
||||
- Gates the market tools until auth is available
|
||||
- Triggers full-scan flow only when filter, sort, or export is used
|
||||
|
||||
## Auth Configuration
|
||||
|
||||
The Logto integration is wired with placeholder values in `src/shared/auth-config.ts`.
|
||||
Replace these before real sign-in testing:
|
||||
|
||||
- `logtoEndpoint`
|
||||
- `appId`
|
||||
- `apiResource`
|
||||
- Any extra scopes beyond `openid`, `profile`, and `offline_access`
|
||||
|
||||
The popup dev panel is controlled by `enableDevAuthPanel`.
|
||||
|
||||
## Popup Behavior
|
||||
|
||||
1. Load the unpacked extension from `dist/`
|
||||
2. Click the extension icon
|
||||
3. Confirm the popup shows `登录 Logto` when unauthenticated
|
||||
4. After real Logto config is added, use the popup to sign in and sign out
|
||||
|
||||
## Protected API Mock Test
|
||||
|
||||
1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
|
||||
2. Run `npm run mock:protected-api`
|
||||
3. Run `npm run build`
|
||||
4. Reload the unpacked extension from `dist/`
|
||||
5. Open the popup and log in
|
||||
6. Click `测试受保护接口`
|
||||
7. Confirm the popup shows JSON containing `"source": "mock-protected-api"` and `"message": "authorized"`
|
||||
|
||||
## Batch Submit Mock Test
|
||||
|
||||
1. Run `npm run mock:protected-api`
|
||||
2. Run `npm run build`
|
||||
3. Reload the unpacked extension from `dist/`
|
||||
4. Open `https://xingtu.cn/ad/creator/market`
|
||||
5. Choose an export range in the plugin toolbar
|
||||
6. Click `提交批次`
|
||||
7. Enter a batch name in the browser prompt
|
||||
8. Confirm the toolbar shows `批次提交成功`
|
||||
9. Confirm the mock batch response accepts the payload and reports the submitted `batchId`
|
||||
|
||||
## Market Auth Gate
|
||||
|
||||
When the market page is opened without a valid auth state, the content script renders
|
||||
`请先登录插件` and does not boot the filter, sort, or export toolbar.
|
||||
|
||||
## Manual Verification
|
||||
|
||||
1. Load the unpacked extension from `dist/`
|
||||
2. Open `https://xingtu.cn/ad/creator/market`
|
||||
3. Confirm the two new columns appear
|
||||
4. Confirm current-page rows move through loading and then render values or failure states
|
||||
5. Apply a threshold filter and confirm the list hides unmatched rows
|
||||
6. Apply a sort and confirm row order changes
|
||||
7. Export CSV and confirm the file includes plugin status and after-search-rate fields
|
||||
3. Confirm the page shows the auth gate until login is available
|
||||
4. After authentication is wired, confirm the two new columns appear
|
||||
5. Confirm current-page rows move through loading and then render values or failure states
|
||||
6. Apply a threshold filter and confirm the list hides unmatched rows
|
||||
7. Apply a sort and confirm row order changes
|
||||
8. Export CSV and confirm the file includes plugin status and after-search-rate fields
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
# Logto Auth Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Logto-based Chrome extension authentication with a popup login entry, background-owned auth flow, and content-script gating so the Xingtu enhancer is unusable until the user is authenticated.
|
||||
|
||||
**Architecture:** Keep Logto-specific behavior inside `src/background/auth/*` and expose a small message contract from `src/shared/auth-messages.ts`. Add a standalone `popup` entry for sign-in / sign-out and dev diagnostics. Gate existing market content initialization behind an auth check so the page only boots the current enhancer after authentication succeeds.
|
||||
|
||||
**Tech Stack:** Chrome MV3, TypeScript, Vitest, tsup build script, `@logto/chrome-extension`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
- `src/shared/auth-config.ts`
|
||||
- `src/shared/auth-messages.ts`
|
||||
- `src/background/auth/types.ts`
|
||||
- `src/background/auth/state.ts`
|
||||
- `src/background/auth/client.ts`
|
||||
- `src/background/auth/controller.ts`
|
||||
- `src/popup/index.html`
|
||||
- `src/popup/index.ts`
|
||||
- `src/popup/view.ts`
|
||||
- `src/content/market/auth-gate.ts`
|
||||
- `tests/auth-config.test.ts`
|
||||
- `tests/auth-messages.test.ts`
|
||||
- `tests/background-auth-controller.test.ts`
|
||||
- `tests/popup-entry.test.ts`
|
||||
- `tests/market-auth-gating.test.ts`
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `src/manifest.json`
|
||||
- `scripts/build.mjs`
|
||||
- `src/background/index.ts`
|
||||
- `src/content/index.ts`
|
||||
- `src/content/market/index.ts`
|
||||
- `tests/background-index.test.ts`
|
||||
- `tests/manifest.test.ts`
|
||||
- `README.md`
|
||||
- `externaldocs/2026-04-21-logto-auth-design.md`
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- `src/shared/auth-config.ts`: parse placeholder config, validate required fields, expose a stable config object.
|
||||
- `src/shared/auth-messages.ts`: define request / response types and type guards for popup-content-background communication.
|
||||
- `src/background/auth/*`: isolate Logto client creation, auth state mapping, and runtime message handling.
|
||||
- `src/popup/*`: render login/logout UI and dev diagnostics using background messages only.
|
||||
- `src/content/market/auth-gate.ts`: render the disabled state and “go login” guidance when auth is missing.
|
||||
|
||||
## Task 1: Shared Auth Contract And Config
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/auth-config.ts`
|
||||
- Create: `src/shared/auth-messages.ts`
|
||||
- Test: `tests/auth-config.test.ts`
|
||||
- Test: `tests/auth-messages.test.ts`
|
||||
- Modify: `package.json`
|
||||
- Modify: `package-lock.json`
|
||||
|
||||
- [ ] **Step 1: Add the Logto SDK dependency in the manifest of the repo**
|
||||
|
||||
Update `package.json` dependencies to include:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@logto/chrome-extension": "^<latest-compatible>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `npm install`
|
||||
Expected: `package-lock.json` includes `@logto/chrome-extension`
|
||||
|
||||
- [ ] **Step 2: Write the failing config tests**
|
||||
|
||||
Create `tests/auth-config.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { readAuthConfig } from "../src/shared/auth-config";
|
||||
|
||||
describe("auth-config", () => {
|
||||
test("returns the placeholder config in development", () => {
|
||||
expect(readAuthConfig()).toEqual({
|
||||
apiResource: "<your-global-api-resource-indicator>",
|
||||
appId: "<chrome-extension-app-id>",
|
||||
enableDevAuthPanel: false,
|
||||
logtoEndpoint: "https://<your-tenant>.logto.app",
|
||||
scopes: ["openid", "profile", "offline_access"]
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects empty endpoint values", () => {
|
||||
expect(() =>
|
||||
readAuthConfig({
|
||||
logtoEndpoint: ""
|
||||
})
|
||||
).toThrow(/logtoEndpoint/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/auth-config.test.ts`
|
||||
Expected: FAIL because `readAuthConfig` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Write the failing message contract tests**
|
||||
|
||||
Create `tests/auth-messages.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
isAuthRequestMessage,
|
||||
isAuthResponseMessage
|
||||
} from "../src/shared/auth-messages";
|
||||
|
||||
describe("auth-messages", () => {
|
||||
test("accepts a get-state request", () => {
|
||||
expect(isAuthRequestMessage({ type: "auth:get-state" })).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown auth requests", () => {
|
||||
expect(isAuthRequestMessage({ type: "auth:wat" })).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts a successful auth response envelope", () => {
|
||||
expect(
|
||||
isAuthResponseMessage({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/auth-messages.test.ts`
|
||||
Expected: FAIL because helpers and types do not exist yet
|
||||
|
||||
- [ ] **Step 4: Implement the minimal shared config**
|
||||
|
||||
Create `src/shared/auth-config.ts` with:
|
||||
|
||||
```ts
|
||||
export interface AuthConfig {
|
||||
apiResource: string;
|
||||
appId: string;
|
||||
enableDevAuthPanel: boolean;
|
||||
logtoEndpoint: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
const defaultAuthConfig: AuthConfig = {
|
||||
apiResource: "<your-global-api-resource-indicator>",
|
||||
appId: "<chrome-extension-app-id>",
|
||||
enableDevAuthPanel: false,
|
||||
logtoEndpoint: "https://<your-tenant>.logto.app",
|
||||
scopes: ["openid", "profile", "offline_access"]
|
||||
};
|
||||
|
||||
export function readAuthConfig(
|
||||
overrides: Partial<AuthConfig> = {}
|
||||
): AuthConfig {
|
||||
const nextConfig = { ...defaultAuthConfig, ...overrides };
|
||||
if (!nextConfig.logtoEndpoint.trim()) {
|
||||
throw new Error("auth config logtoEndpoint is required");
|
||||
}
|
||||
if (!nextConfig.appId.trim()) {
|
||||
throw new Error("auth config appId is required");
|
||||
}
|
||||
if (!nextConfig.apiResource.trim()) {
|
||||
throw new Error("auth config apiResource is required");
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Implement the minimal shared message contract**
|
||||
|
||||
Create `src/shared/auth-messages.ts` with:
|
||||
|
||||
```ts
|
||||
export type AuthRequestMessage =
|
||||
| { type: "auth:get-state" }
|
||||
| { type: "auth:sign-in" }
|
||||
| { type: "auth:sign-out" }
|
||||
| { type: "auth:get-access-token" };
|
||||
|
||||
export interface AuthStateValue {
|
||||
accessTokenExpiresAt?: number | null;
|
||||
isAuthenticated: boolean;
|
||||
lastError?: string | null;
|
||||
resource?: string | null;
|
||||
scopes?: string[];
|
||||
tokenAvailable?: boolean;
|
||||
userInfo?: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
sub?: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AuthResponseMessage =
|
||||
| { ok: true; type: "auth:state"; value: AuthStateValue }
|
||||
| { ok: true; type: "auth:token"; value: { accessToken: string } }
|
||||
| { ok: true; type: "auth:ack" }
|
||||
| { ok: false; type: "auth:error"; error: string };
|
||||
```
|
||||
|
||||
Also add `isAuthRequestMessage()` and `isAuthResponseMessage()` type guards.
|
||||
|
||||
- [ ] **Step 6: Run the shared auth tests and verify green**
|
||||
|
||||
Run: `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the shared contract slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json src/shared/auth-config.ts src/shared/auth-messages.ts tests/auth-config.test.ts tests/auth-messages.test.ts
|
||||
git commit -m "feat: add shared auth config and message contracts"
|
||||
```
|
||||
|
||||
## Task 2: Background Auth Controller
|
||||
|
||||
**Files:**
|
||||
- Create: `src/background/auth/types.ts`
|
||||
- Create: `src/background/auth/state.ts`
|
||||
- Create: `src/background/auth/client.ts`
|
||||
- Create: `src/background/auth/controller.ts`
|
||||
- Modify: `src/background/index.ts`
|
||||
- Test: `tests/background-auth-controller.test.ts`
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing background auth controller tests**
|
||||
|
||||
Create `tests/background-auth-controller.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createAuthController } from "../src/background/auth/controller";
|
||||
|
||||
describe("background-auth-controller", () => {
|
||||
test("returns unauthenticated state when the client is logged out", async () => {
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await expect(controller.getAuthState()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isAuthenticated: false
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("delegates sign in to the auth client", async () => {
|
||||
const signIn = vi.fn(async () => undefined);
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
signIn,
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await controller.signIn();
|
||||
expect(signIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/background-auth-controller.test.ts`
|
||||
Expected: FAIL because the controller does not exist
|
||||
|
||||
- [ ] **Step 2: Extend the existing background runtime test with auth messages**
|
||||
|
||||
Modify `tests/background-index.test.ts` to add:
|
||||
|
||||
```ts
|
||||
test("responds to auth:get-state with auth status", async () => {
|
||||
const listeners: Array<any> = [];
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(),
|
||||
getAuthState: vi.fn(async () => ({ isAuthenticated: false })),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse);
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
Expected: FAIL because `registerBackgroundMessageHandler` only supports CSV download
|
||||
|
||||
- [ ] **Step 3: Implement the minimal background auth controller**
|
||||
|
||||
Create `src/background/auth/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface AuthClientLike {
|
||||
getAccessToken(resource?: string): Promise<string>;
|
||||
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
|
||||
isAuthenticated(): Promise<boolean>;
|
||||
signIn(): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/background/auth/state.ts` with a helper that maps client state into the shared `AuthStateValue`.
|
||||
|
||||
Create `src/background/auth/controller.ts` with:
|
||||
|
||||
```ts
|
||||
export function createAuthController(options: {
|
||||
authClient: AuthClientLike;
|
||||
config?: AuthConfig;
|
||||
}) {
|
||||
return {
|
||||
async getAuthState() {
|
||||
const isAuthenticated = await options.authClient.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
return { isAuthenticated: false, resource: options.config?.apiResource ?? null };
|
||||
}
|
||||
const claims = await options.authClient.getIdTokenClaims();
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
resource: options.config?.apiResource ?? null,
|
||||
scopes: options.config?.scopes ?? [],
|
||||
userInfo: {
|
||||
email: typeof claims?.email === "string" ? claims.email : undefined,
|
||||
name: typeof claims?.name === "string" ? claims.name : undefined,
|
||||
sub: typeof claims?.sub === "string" ? claims.sub : undefined,
|
||||
username: typeof claims?.username === "string" ? claims.username : undefined
|
||||
}
|
||||
};
|
||||
},
|
||||
signIn: () => options.authClient.signIn(),
|
||||
signOut: () => options.authClient.signOut(),
|
||||
async getAccessToken() {
|
||||
return options.authClient.getAccessToken(options.config?.apiResource);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the Logto client factory wrapper**
|
||||
|
||||
Create `src/background/auth/client.ts` as the only place that imports `@logto/chrome-extension`:
|
||||
|
||||
```ts
|
||||
import LogtoClient from "@logto/chrome-extension";
|
||||
|
||||
import { readAuthConfig } from "../../shared/auth-config";
|
||||
|
||||
export function createLogtoAuthClient() {
|
||||
const config = readAuthConfig();
|
||||
return new LogtoClient({
|
||||
appId: config.appId,
|
||||
endpoint: config.logtoEndpoint,
|
||||
resources: [config.apiResource],
|
||||
scopes: config.scopes
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Integrate auth handling into `src/background/index.ts`**
|
||||
|
||||
Refactor `registerBackgroundMessageHandler()` to accept optional dependencies:
|
||||
|
||||
```ts
|
||||
registerBackgroundMessageHandler(chromeLike?, {
|
||||
authController?: createAuthController(...)
|
||||
});
|
||||
```
|
||||
|
||||
Handle:
|
||||
|
||||
- `auth:get-state` -> `{ ok: true, type: "auth:state", value }`
|
||||
- `auth:sign-in` -> `{ ok: true, type: "auth:ack" }`
|
||||
- `auth:sign-out` -> `{ ok: true, type: "auth:ack" }`
|
||||
- `auth:get-access-token` -> `{ ok: true, type: "auth:token", value: { accessToken } }`
|
||||
|
||||
Keep the existing CSV download path intact.
|
||||
|
||||
- [ ] **Step 6: Run background tests and verify green**
|
||||
|
||||
Run: `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the background auth slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/background/index.ts src/background/auth tests/background-auth-controller.test.ts tests/background-index.test.ts
|
||||
git commit -m "feat: add background auth controller"
|
||||
```
|
||||
|
||||
## Task 3: Popup Entry, Manifest, And Build Output
|
||||
|
||||
**Files:**
|
||||
- Create: `src/popup/index.html`
|
||||
- Create: `src/popup/index.ts`
|
||||
- Create: `src/popup/view.ts`
|
||||
- Modify: `src/manifest.json`
|
||||
- Modify: `scripts/build.mjs`
|
||||
- Modify: `tests/manifest.test.ts`
|
||||
- Test: `tests/popup-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing popup rendering tests**
|
||||
|
||||
Create `tests/popup-entry.test.ts` with a JSDOM fixture:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootPopup } from "../src/popup/index";
|
||||
|
||||
describe("popup-entry", () => {
|
||||
test("renders a sign-in button when unauthenticated", async () => {
|
||||
document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
}))
|
||||
});
|
||||
|
||||
expect(document.querySelector("button")?.textContent).toContain("登录");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
Expected: FAIL because popup files do not exist
|
||||
|
||||
- [ ] **Step 2: Extend manifest tests for auth permissions and popup**
|
||||
|
||||
Modify `tests/manifest.test.ts` to assert:
|
||||
|
||||
```ts
|
||||
expect(manifest.permissions).toEqual(
|
||||
expect.arrayContaining(["downloads", "identity", "storage"])
|
||||
);
|
||||
expect(manifest.action?.default_popup).toBe("popup/index.html");
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/manifest.test.ts`
|
||||
Expected: FAIL because manifest does not include popup / auth permissions
|
||||
|
||||
- [ ] **Step 3: Implement the minimal popup view**
|
||||
|
||||
Create `src/popup/view.ts` with rendering helpers:
|
||||
|
||||
```ts
|
||||
export function renderLoggedOut(root: HTMLElement, error?: string | null) {
|
||||
root.innerHTML = `
|
||||
<section data-popup-state="logged-out">
|
||||
<h1>Star Chart Search Enhancer</h1>
|
||||
<p>登录后才能使用星图增强功能</p>
|
||||
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
||||
<button type="button" data-popup-sign-in="button">登录 Logto</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
Also add `renderLoggedIn()` and `renderDevPanel()` helpers.
|
||||
|
||||
- [ ] **Step 4: Implement the minimal popup bootstrap**
|
||||
|
||||
Create `src/popup/index.ts` with:
|
||||
|
||||
```ts
|
||||
import { renderLoggedIn, renderLoggedOut } from "./view";
|
||||
|
||||
export async function bootPopup(options = {}) {
|
||||
const currentDocument = options.document ?? document;
|
||||
const root = currentDocument.querySelector("#app") as HTMLElement | null;
|
||||
if (!root) {
|
||||
throw new Error("popup root #app is required");
|
||||
}
|
||||
const sendMessage =
|
||||
options.sendMessage ??
|
||||
((message: unknown) =>
|
||||
chrome.runtime.sendMessage(message) as Promise<unknown>);
|
||||
|
||||
const stateResponse = await sendMessage({ type: "auth:get-state" });
|
||||
if (!stateResponse.ok || stateResponse.type !== "auth:state") {
|
||||
renderLoggedOut(root, "认证状态读取失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stateResponse.value.isAuthenticated) {
|
||||
renderLoggedOut(root, stateResponse.value.lastError);
|
||||
root.querySelector("[data-popup-sign-in='button']")?.addEventListener("click", async () => {
|
||||
await sendMessage({ type: "auth:sign-in" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
renderLoggedIn(root, stateResponse.value);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/popup/index.html`:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Star Chart Search Enhancer</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update build and manifest**
|
||||
|
||||
Modify `scripts/build.mjs` to:
|
||||
|
||||
- build `src/popup/index.ts` into `dist/popup/index.js`
|
||||
- create `dist/popup`
|
||||
- copy `src/popup/index.html` into `dist/popup/index.html`
|
||||
|
||||
Modify `src/manifest.json` to:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["downloads", "identity", "storage"],
|
||||
"action": {
|
||||
"default_popup": "popup/index.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Preserve current background and content script entries.
|
||||
|
||||
- [ ] **Step 6: Run popup and manifest tests**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the popup and manifest slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/popup src/manifest.json scripts/build.mjs tests/popup-entry.test.ts tests/manifest.test.ts
|
||||
git commit -m "feat: add popup auth entry and manifest wiring"
|
||||
```
|
||||
|
||||
## Task 4: Content Auth Gating
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/auth-gate.ts`
|
||||
- Modify: `src/content/index.ts`
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Test: `tests/market-auth-gating.test.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing content gating test**
|
||||
|
||||
Create `tests/market-auth-gating.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootContentScript } from "../src/content/index";
|
||||
|
||||
describe("market-auth-gating", () => {
|
||||
test("shows a login gate instead of booting the market controller when unauthenticated", async () => {
|
||||
document.body.innerHTML = "<div></div>";
|
||||
|
||||
const createMarketController = vi.fn();
|
||||
await bootContentScript({
|
||||
createMarketController,
|
||||
document,
|
||||
sendAuthMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
})),
|
||||
window
|
||||
});
|
||||
|
||||
expect(createMarketController).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("请先登录插件");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/market-auth-gating.test.ts`
|
||||
Expected: FAIL because `bootContentScript` does not gate on auth yet
|
||||
|
||||
- [ ] **Step 2: Add an integration assertion to the existing content entry tests**
|
||||
|
||||
Modify `tests/market-content-entry.test.ts` to add:
|
||||
|
||||
```ts
|
||||
test("boots the controller only after auth succeeds", async () => {
|
||||
const createMarketController = vi.fn(() => ({ ready: Promise.resolve() }));
|
||||
await bootContentScript({
|
||||
createMarketController,
|
||||
document,
|
||||
sendAuthMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: true }
|
||||
})),
|
||||
window
|
||||
});
|
||||
expect(createMarketController).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: FAIL until `bootContentScript` accepts the new auth dependency
|
||||
|
||||
- [ ] **Step 3: Implement the auth gate UI**
|
||||
|
||||
Create `src/content/market/auth-gate.ts`:
|
||||
|
||||
```ts
|
||||
export function renderMarketAuthGate(document: Document): HTMLElement {
|
||||
const root = document.createElement("section");
|
||||
root.dataset.marketAuthGate = "root";
|
||||
root.innerHTML = `
|
||||
<strong>请先登录插件</strong>
|
||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||
<button type="button" data-market-auth-help="button">去登录</button>
|
||||
`;
|
||||
root.querySelector("[data-market-auth-help='button']")?.addEventListener("click", () => {
|
||||
window.alert("请点击浏览器工具栏中的扩展图标完成登录");
|
||||
});
|
||||
document.body.prepend(root);
|
||||
return root;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate `bootContentScript()` before controller startup**
|
||||
|
||||
Modify `src/content/index.ts` to:
|
||||
|
||||
- accept an injectable `sendAuthMessage`
|
||||
- call background with `{ type: "auth:get-state" }`
|
||||
- if unauthenticated, render the gate and return `{ ready: Promise.resolve() }` or `null`
|
||||
- only call `createMarketController()` after auth succeeds
|
||||
|
||||
Suggested helper:
|
||||
|
||||
```ts
|
||||
async function readAuthState(
|
||||
sendMessage: (message: unknown) => Promise<unknown>
|
||||
): Promise<AuthStateValue | null> {
|
||||
const response = await sendMessage({ type: "auth:get-state" });
|
||||
return response.ok && response.type === "auth:state" ? response.value : null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Keep market controller auth-agnostic**
|
||||
|
||||
Only touch `src/content/market/index.ts` if the existing controller assumes it always owns the toolbar root. Prefer to keep auth gating in `src/content/index.ts`; only add defensive guards if the current toolbar boot sequence conflicts with the auth gate DOM.
|
||||
|
||||
- [ ] **Step 6: Run content gating tests**
|
||||
|
||||
Run: `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the auth gate slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/index.ts src/content/market/auth-gate.ts src/content/market/index.ts tests/market-auth-gating.test.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: gate market tools behind authentication"
|
||||
```
|
||||
|
||||
## Task 5: Dev Diagnostics, Build Verification, And Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `src/popup/index.ts`
|
||||
- Modify: `src/background/auth/controller.ts`
|
||||
- Modify: `README.md`
|
||||
- Modify: `externaldocs/2026-04-21-logto-auth-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing popup dev panel test**
|
||||
|
||||
Extend `tests/popup-entry.test.ts` with:
|
||||
|
||||
```ts
|
||||
test("renders the dev auth panel when enabled", async () => {
|
||||
document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
accessTokenExpiresAt: 1700000000000,
|
||||
isAuthenticated: true,
|
||||
resource: "https://api.example.test",
|
||||
scopes: ["openid", "profile"],
|
||||
tokenAvailable: true,
|
||||
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("resource");
|
||||
expect(document.body.textContent).toContain("token");
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
Expected: FAIL because the dev panel is not rendered yet
|
||||
|
||||
- [ ] **Step 2: Implement minimal diagnostics**
|
||||
|
||||
Update popup rendering to show when `enableDevAuthPanel` is true:
|
||||
|
||||
- `resource`
|
||||
- `scopes`
|
||||
- `tokenAvailable`
|
||||
- `accessTokenExpiresAt`
|
||||
- `lastError`
|
||||
|
||||
Update background state mapping to return `tokenAvailable` and `accessTokenExpiresAt` placeholders even when the exact expiry cannot be resolved yet:
|
||||
|
||||
```ts
|
||||
return {
|
||||
accessTokenExpiresAt: null,
|
||||
isAuthenticated: true,
|
||||
resource: config.apiResource,
|
||||
scopes: config.scopes,
|
||||
tokenAvailable: true,
|
||||
userInfo
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the popup suite and the full test suite**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS across existing and new tests
|
||||
|
||||
- [ ] **Step 4: Run the build and verify output structure**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected:
|
||||
|
||||
- `dist/manifest.json`
|
||||
- `dist/background/index.js`
|
||||
- `dist/content/index.js`
|
||||
- `dist/content/market-page-bridge.js`
|
||||
- `dist/popup/index.html`
|
||||
- `dist/popup/index.js`
|
||||
|
||||
- [ ] **Step 5: Update docs**
|
||||
|
||||
Update `README.md` with:
|
||||
|
||||
- how to load the popup
|
||||
- what placeholder Logto config values must be replaced
|
||||
- what “unauthenticated” looks like on the market page
|
||||
|
||||
Update `externaldocs/2026-04-21-logto-auth-design.md` only if implementation choices diverge from the design. Also fix any current encoding artifacts in that spec while editing.
|
||||
|
||||
- [ ] **Step 6: Commit the verification and docs slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/popup/view.ts src/popup/index.ts src/background/auth/controller.ts README.md externaldocs/2026-04-21-logto-auth-design.md
|
||||
git commit -m "docs: document logto auth flow and diagnostics"
|
||||
```
|
||||
|
||||
## Final Verification Checklist
|
||||
|
||||
- [ ] `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts`
|
||||
- [ ] `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts`
|
||||
- [ ] `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts`
|
||||
- [ ] `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts`
|
||||
- [ ] `npm test`
|
||||
- [ ] `npm run build`
|
||||
- [ ] Load `dist/` in `chrome://extensions`
|
||||
- [ ] Verify popup shows logged-out state before configuration is real
|
||||
- [ ] Verify market page shows auth gate when unauthenticated
|
||||
|
||||
## Open Inputs Still Required From The User
|
||||
|
||||
- Real `logtoEndpoint`
|
||||
- Real `appId`
|
||||
- Real `apiResource`
|
||||
- Any extra scopes beyond `openid profile offline_access`
|
||||
- Whether dev auth panel should be hard-disabled in production builds or controlled by config only
|
||||
@@ -0,0 +1,217 @@
|
||||
# Market Export Range Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add configurable CSV export ranges for the Xingtu market plugin, defaulting to the first 5 pages and supporting current page, preset ranges, all pages, and custom page counts.
|
||||
|
||||
**Architecture:** Keep filter and sort scoped to the current page, and introduce a dedicated batch-export path that only runs when exporting. Extend the toolbar with export-range controls, extract page-navigation/signature helpers, and add a focused controller that iterates pages, harvests lazy-loaded DOM fields, merges records by `authorId`, and returns a single record set for CSV generation.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, jsdom, Chrome MV3 content script
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend Toolbar Controls For Export Range
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add tests that assert:
|
||||
- the toolbar defaults to `前5页`
|
||||
- selecting `自定义` shows and uses a page-count input
|
||||
- invalid custom values prevent export
|
||||
- export mode disables toolbar controls during a running task
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
|
||||
Expected: FAIL because the toolbar has no range selector or custom input behavior.
|
||||
|
||||
- [ ] **Step 3: Write the minimal implementation**
|
||||
|
||||
Update `plugin-toolbar.ts` to:
|
||||
- add an export-range `<select>`
|
||||
- add a custom page-count `<input>`
|
||||
- add status text support
|
||||
- expose helpers for reading range state and toggling disabled/running UI
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add export range toolbar controls"
|
||||
```
|
||||
|
||||
### Task 2: Add Batch Export Coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
- Modify: `tests/market-dom-sync.test.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add tests that assert:
|
||||
- `当前页` exports only the current page
|
||||
- `前5页` iterates through pages until it reaches page 5 or runs out of pages
|
||||
- `全部` stops when the next-page control is no longer usable
|
||||
- repeated authors across pages are merged by `authorId`
|
||||
- empty fields from later pages do not erase existing populated fields
|
||||
- page export waits for page signature change before harvesting the next page
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: FAIL on the new batch-export expectations.
|
||||
|
||||
- [ ] **Step 3: Write the minimal implementation scaffolding**
|
||||
|
||||
Only introduce the minimum new helpers/types needed to make the tests compile:
|
||||
- export range type(s)
|
||||
- page signature helpers
|
||||
- harness utilities in tests
|
||||
|
||||
- [ ] **Step 4: Run tests to verify the failures are now behavioral**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: FAIL because batch export logic is still missing, not because types/selectors are missing.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/market-content-entry.test.ts tests/market-dom-sync.test.ts src/content/market/types.ts
|
||||
git commit -m "test: cover multi-page export behavior"
|
||||
```
|
||||
|
||||
### Task 3: Implement Batch Export Controller
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/export-range-controller.ts`
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
- Test: `tests/market-dom-sync.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add focused tests for:
|
||||
- reading a page signature from current DOM
|
||||
- detecting/using the next-page control
|
||||
- merging rows by `authorId` with non-empty-field preference
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: FAIL because these helpers/controller do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write the minimal implementation**
|
||||
|
||||
Implement `export-range-controller.ts` to:
|
||||
- normalize export targets (`current`, `count`, `all`)
|
||||
- harvest current page rows with the existing lazy-load scroll path
|
||||
- read page signatures
|
||||
- click/advance pagination and wait for signature change
|
||||
- merge pages by `authorId` without overwriting non-empty fields
|
||||
- return a final `MarketRecord[]`
|
||||
|
||||
Keep `dom-sync.ts` focused on DOM reads:
|
||||
- page signature
|
||||
- next-page element lookup
|
||||
- next-page enabled check
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run:
|
||||
- `npm test -- tests/market-dom-sync.test.ts`
|
||||
- `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/export-range-controller.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts tests/market-content-entry.test.ts tests/market-dom-sync.test.ts
|
||||
git commit -m "feat: add multi-page export controller"
|
||||
```
|
||||
|
||||
### Task 4: Wire Controller Into Market Entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add tests that assert:
|
||||
- `导出CSV` uses the selected range
|
||||
- progress text updates while exporting
|
||||
- controls are re-enabled on success and failure
|
||||
- invalid custom input blocks export without calling CSV builder
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: FAIL because `market/index.ts` still always exports current-page records.
|
||||
|
||||
- [ ] **Step 3: Write the minimal implementation**
|
||||
|
||||
Update `market/index.ts` to:
|
||||
- parse toolbar export range
|
||||
- keep current-page export for `当前页`
|
||||
- delegate batch modes to `export-range-controller.ts`
|
||||
- update toolbar running/error/progress state
|
||||
- preserve existing current-page lazy-load harvest path
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: wire export range selection into market export"
|
||||
```
|
||||
|
||||
### Task 5: Verify End-To-End
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/plans/2026-04-21-market-export-range.md`
|
||||
|
||||
- [ ] **Step 1: Run targeted regression tests**
|
||||
|
||||
Run:
|
||||
- `npm test -- tests/market-content-entry.test.ts`
|
||||
- `npm test -- tests/market-dom-sync.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run the full suite**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS with all test files green.
|
||||
|
||||
- [ ] **Step 3: Run the build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: PASS and emit updated `dist/` bundles.
|
||||
|
||||
- [ ] **Step 4: Mark the plan status**
|
||||
|
||||
Update this document’s checkboxes to reflect completed steps.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-04-21-market-export-range.md
|
||||
git commit -m "docs: record export range implementation progress"
|
||||
```
|
||||
@@ -0,0 +1,710 @@
|
||||
# Logto Protected API Mock Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a reusable protected-API client that pulls a Logto access token from the extension background, sends `Authorization: Bearer <token>` to a local mock API, and exposes a dev-only popup flow to verify the end-to-end request path before a real backend exists.
|
||||
|
||||
**Architecture:** Keep Logto token ownership in the background and add a small reusable client in `src/shared` so both popup and future content code can call protected APIs the same way. Add a tiny local Node mock server that only checks for a Bearer header and returns fixed JSON. Use popup dev UI as the manual test entrypoint because it already has an authenticated extension context and avoids coupling the mock flow to the Xingtu page DOM.
|
||||
|
||||
**Tech Stack:** Chrome MV3, TypeScript, Vitest, Node HTTP server, tsup, `@logto/chrome-extension`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
- `src/shared/protected-api-client.ts`
|
||||
- `scripts/mock-protected-api.mjs`
|
||||
- `tests/protected-api-client.test.ts`
|
||||
- `tests/mock-protected-api.test.ts`
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- `src/popup/index.ts`
|
||||
- `src/popup/view.ts`
|
||||
- `tests/popup-entry.test.ts`
|
||||
- `tests/background-index.test.ts`
|
||||
- `package.json`
|
||||
- `README.md`
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- `src/shared/protected-api-client.ts`: reusable client that asks background for a token, injects the Bearer header, and normalizes success / unauthorized / missing-token errors.
|
||||
- `scripts/mock-protected-api.mjs`: minimal local server with one protected endpoint and a CLI start mode.
|
||||
- `src/popup/view.ts`: add a dev-only “测试受保护接口” button and a response/error output area.
|
||||
- `src/popup/index.ts`: wire the dev button to the protected API client and refresh the popup state after calls.
|
||||
- `tests/protected-api-client.test.ts`: lock down token injection and error behavior.
|
||||
- `tests/mock-protected-api.test.ts`: verify the mock server authorizes Bearer requests and rejects missing headers.
|
||||
- `tests/popup-entry.test.ts`: verify popup dev action triggers the protected client and renders results.
|
||||
|
||||
## Task 1: Lock Down The Existing Token Message Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
- Modify: `src/background/index.ts` only if the test exposes a gap
|
||||
|
||||
- [ ] **Step 1: Write the failing background token response test**
|
||||
|
||||
Add a case to `tests/background-index.test.ts`:
|
||||
|
||||
```ts
|
||||
test("responds to auth:get-access-token with the current token", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(async () => "test-access-token"),
|
||||
getAuthState: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0]({ type: "auth:get-access-token" }, {}, sendResponse);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "test-access-token" }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused background test**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
|
||||
Expected:
|
||||
- If it fails because token handling is broken or missing, continue to Step 3.
|
||||
- If it passes immediately, keep the test as a regression and skip production changes for this task.
|
||||
|
||||
- [ ] **Step 3: Make the minimal background fix if needed**
|
||||
|
||||
Ensure `src/background/index.ts` handles:
|
||||
|
||||
```ts
|
||||
if (message.type === "auth:get-access-token") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: {
|
||||
accessToken: await authController.getAccessToken()
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run the background test and confirm green**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the token-contract slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add tests/background-index.test.ts src/background/index.ts
|
||||
git commit -m "test: cover auth access token responses"
|
||||
```
|
||||
|
||||
## Task 2: Build The Reusable Protected API Client
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/protected-api-client.ts`
|
||||
- Create: `tests/protected-api-client.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing protected client tests**
|
||||
|
||||
Create `tests/protected-api-client.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createProtectedApiClient } from "../src/shared/protected-api-client";
|
||||
|
||||
describe("protected-api-client", () => {
|
||||
test("requests a token before calling the protected endpoint", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}));
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true })
|
||||
}));
|
||||
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await client.loadProtectedMockData();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:get-access-token" });
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:4319/api/mock/protected",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer abc123"
|
||||
}),
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws before fetch when the token is unavailable", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: false,
|
||||
type: "auth:error",
|
||||
error: "token missing"
|
||||
}));
|
||||
const fetchImpl = vi.fn();
|
||||
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await expect(client.loadProtectedMockData()).rejects.toThrow(/token/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws an authorization error on 401", async () => {
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ ok: false, error: "unauthorized" })
|
||||
})),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}))
|
||||
});
|
||||
|
||||
await expect(client.loadProtectedMockData()).rejects.toThrow(/unauthorized/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the protected client test to verify red**
|
||||
|
||||
Run: `npm test -- tests/protected-api-client.test.ts`
|
||||
|
||||
Expected: FAIL because `createProtectedApiClient` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal protected client**
|
||||
|
||||
Create `src/shared/protected-api-client.ts` with:
|
||||
|
||||
```ts
|
||||
import { isAuthResponseMessage } from "./auth-messages";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||
|
||||
export function createProtectedApiClient(options: {
|
||||
baseUrl: string;
|
||||
fetchImpl?: FetchLike;
|
||||
sendMessage: SendMessageLike;
|
||||
}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
return {
|
||||
async loadProtectedMockData() {
|
||||
const token = await readAccessToken(options.sendMessage);
|
||||
const response = await fetchImpl(
|
||||
new URL("/api/mock/protected", options.baseUrl).toString(),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
method: "GET"
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("protected api unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`protected api request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||
if (
|
||||
!isAuthResponseMessage(response) ||
|
||||
!response.ok ||
|
||||
response.type !== "auth:token" ||
|
||||
!response.value.accessToken.trim()
|
||||
) {
|
||||
throw new Error("protected api token unavailable");
|
||||
}
|
||||
return response.value.accessToken;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the protected client test to verify green**
|
||||
|
||||
Run: `npm test -- tests/protected-api-client.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Refactor only if tests stay green**
|
||||
|
||||
Optional small cleanup only:
|
||||
- extract URL builder helper
|
||||
- extract `buildAuthorizationHeaders(token)` helper if repeated
|
||||
|
||||
Run: `npm test -- tests/protected-api-client.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the client slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/shared/protected-api-client.ts tests/protected-api-client.test.ts
|
||||
git commit -m "feat: add protected api client"
|
||||
```
|
||||
|
||||
## Task 3: Add A Popup Dev Trigger For Manual Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/popup/index.ts`
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `tests/popup-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing popup dev-flow tests**
|
||||
|
||||
Add cases to `tests/popup-entry.test.ts` for:
|
||||
|
||||
```ts
|
||||
test("renders a protected api test button in the dev panel", async () => {
|
||||
const { bootPopup } = await import("../src/popup/index");
|
||||
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
tokenAvailable: true
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
expect(document.querySelector('[data-popup-test-protected-api="button"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("clicking the dev button runs the protected api client and prints the result", async () => {
|
||||
const sendMessage = vi.fn(async (message: { type: string }) => {
|
||||
if (message.type === "auth:get-state") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
tokenAvailable: true
|
||||
}
|
||||
};
|
||||
}
|
||||
if (message.type === "auth:get-access-token") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: {
|
||||
accessToken: "abc123"
|
||||
}
|
||||
};
|
||||
}
|
||||
return { ok: true, type: "auth:ack" };
|
||||
});
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, message: "authorized" })
|
||||
}));
|
||||
|
||||
const { bootPopup } = await import("../src/popup/index");
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document,
|
||||
fetchProtectedApi: () =>
|
||||
createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
}).loadProtectedMockData(),
|
||||
sendMessage
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-popup-test-protected-api="button"]')
|
||||
?.dispatchEvent(new MouseEvent("click"));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(document.body.textContent).toContain("authorized");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the popup tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
|
||||
Expected: FAIL because the popup dev test entrypoint does not exist yet
|
||||
|
||||
- [ ] **Step 3: Add the minimal popup UI**
|
||||
|
||||
Update `src/popup/view.ts` so `renderDevPanel()` also renders:
|
||||
|
||||
```html
|
||||
<button type="button" data-popup-test-protected-api="button">
|
||||
测试受保护接口
|
||||
</button>
|
||||
<pre data-popup-protected-api-result="output"></pre>
|
||||
```
|
||||
|
||||
Add a small helper for setting the output:
|
||||
|
||||
```ts
|
||||
export function setProtectedApiResult(root: HTMLElement, value: string): void {
|
||||
root
|
||||
.querySelector('[data-popup-protected-api-result="output"]')
|
||||
?.replaceChildren(value);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the popup button to the protected client**
|
||||
|
||||
Update `src/popup/index.ts`:
|
||||
- inject an optional `fetchProtectedApi` dependency
|
||||
- default it to a `createProtectedApiClient(...)` instance using `sendMessage`
|
||||
- attach a click handler only when the dev panel is enabled and the user is logged in
|
||||
- write either the JSON response or the thrown error message into the output area
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```ts
|
||||
interface BootPopupOptions {
|
||||
config?: Partial<AuthConfig>;
|
||||
document?: Document;
|
||||
fetchProtectedApi?: () => Promise<unknown>;
|
||||
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the popup tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the popup dev slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/popup/index.ts src/popup/view.ts tests/popup-entry.test.ts
|
||||
git commit -m "feat: add popup protected api dev test"
|
||||
```
|
||||
|
||||
## Task 4: Add The Local Mock Protected API Server
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/mock-protected-api.mjs`
|
||||
- Create: `tests/mock-protected-api.test.ts`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Write the failing mock server tests**
|
||||
|
||||
Create `tests/mock-protected-api.test.ts` with:
|
||||
|
||||
```ts
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createMockProtectedApiServer } from "../scripts/mock-protected-api.mjs";
|
||||
|
||||
const servers: Array<{ close: () => Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (servers.length > 0) {
|
||||
await servers.pop()?.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("mock-protected-api", () => {
|
||||
test("returns mock data when a Bearer token is present", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
servers.push(server);
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/protected`, {
|
||||
headers: {
|
||||
Authorization: "Bearer abc123"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
source: "mock-protected-api"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 401 when the Authorization header is missing", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
servers.push(server);
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/protected`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "unauthorized"
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the mock server tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: FAIL because the mock server module does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal mock server**
|
||||
|
||||
Create `scripts/mock-protected-api.mjs` with:
|
||||
|
||||
```js
|
||||
import http from "node:http";
|
||||
|
||||
export function createMockProtectedApiServer({ port = 4319 } = {}) {
|
||||
let server;
|
||||
|
||||
return {
|
||||
get baseUrl() {
|
||||
const address = server?.address();
|
||||
const resolvedPort =
|
||||
typeof address === "object" && address ? address.port : port;
|
||||
return `http://127.0.0.1:${resolvedPort}`;
|
||||
},
|
||||
async start() {
|
||||
server = http.createServer((request, response) => {
|
||||
if (request.url !== "/api/mock/protected") {
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "not-found" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = request.headers.authorization ?? "";
|
||||
const isBearer =
|
||||
typeof authHeader === "string" &&
|
||||
authHeader.startsWith("Bearer ") &&
|
||||
authHeader.length > "Bearer ".length;
|
||||
|
||||
if (!isBearer) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
source: "mock-protected-api",
|
||||
message: "authorized",
|
||||
receivedAuthHeader: authHeader
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
},
|
||||
async close() {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const server = createMockProtectedApiServer();
|
||||
await server.start();
|
||||
console.log(`mock protected api listening on ${server.baseUrl}`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add a package script to start the mock API**
|
||||
|
||||
Update `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"mock:protected-api": "node scripts/mock-protected-api.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the mock server tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the mock server slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add scripts/mock-protected-api.mjs tests/mock-protected-api.test.ts package.json
|
||||
git commit -m "feat: add mock protected api server"
|
||||
```
|
||||
|
||||
## Task 5: Document And Verify The End-To-End Local Flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing documentation expectation**
|
||||
|
||||
Before editing docs, list the missing verification steps in a scratch note:
|
||||
- how to enable the dev panel
|
||||
- how to start the mock server
|
||||
- how to click the popup test button
|
||||
- what success and failure look like
|
||||
|
||||
Expected: current `README.md` does not explain this flow yet
|
||||
|
||||
- [ ] **Step 2: Add the manual verification section**
|
||||
|
||||
Update `README.md` with a short section:
|
||||
|
||||
```md
|
||||
## Protected API Mock Test
|
||||
|
||||
1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
|
||||
2. Run `npm run mock:protected-api`
|
||||
3. Run `npm run build`
|
||||
4. Reload the unpacked extension
|
||||
5. Open the popup and log in
|
||||
6. Click `测试受保护接口`
|
||||
7. Confirm the popup shows a JSON result containing `"source": "mock-protected-api"`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the relevant automated tests together**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/background-index.test.ts tests/protected-api-client.test.ts tests/popup-entry.test.ts tests/mock-protected-api.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Perform the manual smoke test**
|
||||
|
||||
Run in separate terminals:
|
||||
|
||||
```bash
|
||||
npm run mock:protected-api
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected:
|
||||
- terminal 1 prints `mock protected api listening on http://127.0.0.1:4319`
|
||||
- extension loads from `dist/`
|
||||
- popup dev panel can trigger the protected endpoint
|
||||
- success output includes `"message": "authorized"`
|
||||
|
||||
- [ ] **Step 5: Commit the docs and verification slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: add protected api mock verification steps"
|
||||
```
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] Run the full test suite:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] Run the production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build completes and writes updated assets to `dist/`
|
||||
|
||||
- [ ] Confirm the only functional additions are:
|
||||
- background token response covered by tests
|
||||
- reusable protected API client
|
||||
- popup dev test button and result output
|
||||
- local mock protected API server
|
||||
- updated verification docs
|
||||
@@ -0,0 +1,258 @@
|
||||
# Market Backend Metrics Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Connect the extension to the real backend creator search API and render six backend metrics in a new `秒探指标` column while preserving the two existing Xingtu rate columns.
|
||||
|
||||
**Architecture:** Add a background-owned backend metrics request path that uses the existing Logto access token, then batch-request the visible page's `star_id` values from the content layer and map results back onto per-row UI state. Keep Xingtu-sourced rates untouched and add a second metrics structure for the backend-only values.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 background messaging, existing Logto auth flow, Vitest, tsup
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/shared/auth-messages.ts`
|
||||
- Add a background message contract for backend metrics search.
|
||||
- Create: `src/shared/backend-metrics-config.ts`
|
||||
- Hold the default backend base URL in one place.
|
||||
- Create: `src/shared/backend-metrics-client.ts`
|
||||
- Build the backend request and map response rows into extension-friendly metric objects.
|
||||
- Modify: `src/background/index.ts`
|
||||
- Handle the new backend metrics runtime message and call the backend client with Logto token.
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Add types for the six backend metrics and merged row state.
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Persist backend metrics, loading, success, missing, and failure states.
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Add one `秒探指标` column and render loading/success/missing/failure states.
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Batch-load visible page `star_id` values through background and update row state.
|
||||
- Test: `tests/backend-metrics-client.test.ts`
|
||||
- Verify request payloads and response mapping.
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
- Verify background handles backend metrics requests.
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
- Verify the content controller batches visible `star_id` values and renders the new metrics column.
|
||||
- Modify: `tests/market-dom-sync.test.ts`
|
||||
- Verify the new UI cell states.
|
||||
- Modify: `tests/auth-messages.test.ts`
|
||||
- Verify new message guards.
|
||||
|
||||
### Task 1: Shared Backend Metrics Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/backend-metrics-config.ts`
|
||||
- Create: `src/shared/backend-metrics-client.ts`
|
||||
- Modify: `src/shared/auth-messages.ts`
|
||||
- Test: `tests/backend-metrics-client.test.ts`
|
||||
- Test: `tests/auth-messages.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing shared tests**
|
||||
|
||||
Add tests for:
|
||||
- default backend base URL export
|
||||
- request body shape using `type: "star_id"`
|
||||
- mapping backend fields:
|
||||
- `avg_after_view_search_rate`
|
||||
- `avg_after_view_search_cnt`
|
||||
- `avg_a3_increase_cnt`
|
||||
- `avg_new_a3_rate`
|
||||
- `cpa3`
|
||||
- `cp_search`
|
||||
- new runtime message guard recognition
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts`
|
||||
Expected: FAIL because the backend metrics files and message shapes do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal shared implementation**
|
||||
|
||||
Implement:
|
||||
- `DEFAULT_BACKEND_METRICS_BASE_URL`
|
||||
- a backend client that posts to `/api/v1/history/talents/search`
|
||||
- request/response helpers for runtime messaging
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/backend-metrics-config.ts src/shared/backend-metrics-client.ts src/shared/auth-messages.ts tests/backend-metrics-client.test.ts tests/auth-messages.test.ts
|
||||
git commit -m "feat: add backend metrics shared client"
|
||||
```
|
||||
|
||||
### Task 2: Background Search Bridge
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/background/index.ts`
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing background test**
|
||||
|
||||
Add a test for a runtime message like:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "backend-metrics:search",
|
||||
value: {
|
||||
starIds: ["111", "222"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
- background reads the Logto access token
|
||||
- background calls the backend metrics client
|
||||
- background returns `{ ok: true, type: "backend-metrics:result", value: ... }`
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
Expected: FAIL because background does not handle the new message.
|
||||
|
||||
- [ ] **Step 3: Write minimal background implementation**
|
||||
|
||||
Add:
|
||||
- a new message type guard branch
|
||||
- lazy creation of the backend metrics client
|
||||
- token injection via the existing auth controller
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/background/index.ts tests/background-index.test.ts
|
||||
git commit -m "feat: wire backend metrics search in background"
|
||||
```
|
||||
|
||||
### Task 3: Row State and DOM Rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Modify: `tests/market-dom-sync.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing DOM rendering tests**
|
||||
|
||||
Add tests for:
|
||||
- header contains `秒探指标`
|
||||
- success state renders six metric labels and values
|
||||
- loading state renders `加载中...`
|
||||
- missing state renders `暂无数据`
|
||||
- failed state renders `加载失败`
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: FAIL because the new column and states are not implemented.
|
||||
|
||||
- [ ] **Step 3: Write minimal row-state and rendering implementation**
|
||||
|
||||
Add:
|
||||
- backend metrics types
|
||||
- store methods for backend metrics status transitions
|
||||
- DOM helpers to insert and render the compact metrics panel
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/types.ts src/content/market/result-store.ts src/content/market/dom-sync.ts tests/market-dom-sync.test.ts
|
||||
git commit -m "feat: render backend metrics column"
|
||||
```
|
||||
|
||||
### Task 4: Content Batch Loading
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing content integration tests**
|
||||
|
||||
Add tests for:
|
||||
- collecting visible page `authorId` values as `star_id[]`
|
||||
- sending one background request for the page instead of one request per row
|
||||
- mapping result rows by `star_id`
|
||||
- rendering `暂无数据` when a row is absent from backend response
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: FAIL because the content controller still uses per-row Xingtu loading only.
|
||||
|
||||
- [ ] **Step 3: Write minimal content implementation**
|
||||
|
||||
Change the controller so it:
|
||||
- leaves old Xingtu rates logic intact
|
||||
- batches backend metrics requests per visible page
|
||||
- merges backend results into the store by `authorId`
|
||||
- updates the new metrics cell state
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: batch load backend metrics for market rows"
|
||||
```
|
||||
|
||||
### Task 5: Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify only if needed based on failures
|
||||
|
||||
- [ ] **Step 1: Run focused backend metrics test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts tests/background-index.test.ts tests/market-dom-sync.test.ts tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run production build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: PASS and updated assets in `dist/`
|
||||
|
||||
- [ ] **Step 4: Manual verification checklist**
|
||||
|
||||
Verify in the extension:
|
||||
- popup login still works
|
||||
- current page still shows old two Xingtu rate columns
|
||||
- new `秒探指标` column appears
|
||||
- rows show loading before data arrives
|
||||
- success rows show six backend metrics
|
||||
- unmatched rows show `暂无数据`
|
||||
- backend/network issues show `加载失败`
|
||||
|
||||
- [ ] **Step 5: Final commit**
|
||||
|
||||
```bash
|
||||
git add src/background/index.ts src/content/market/index.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts src/shared/backend-metrics-config.ts src/shared/backend-metrics-client.ts src/shared/auth-messages.ts tests/backend-metrics-client.test.ts tests/auth-messages.test.ts tests/background-index.test.ts tests/market-dom-sync.test.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add backend metrics to market plugin"
|
||||
```
|
||||
@@ -0,0 +1,697 @@
|
||||
# Market Batch Submit Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a standalone `提交批次` toolbar action that collects the currently selected export range of Xingtu creators, prompts for a batch name, builds a batch payload with Logto user identity plus creator IDs, and submits the batch to a protected API endpoint with a Bearer token.
|
||||
|
||||
**Architecture:** Reuse the existing export-range collection path so multi-page batch submission behaves exactly like CSV export. Keep responsibilities split: toolbar UI emits a submit intent, a batch-payload module assembles the request body, and a protected batch-submit client owns token injection plus POST behavior. For first-pass verification, extend the local mock server with a batch endpoint that echoes the submitted payload.
|
||||
|
||||
**Tech Stack:** Chrome MV3, TypeScript, Vitest, tsup, Node HTTP server, `@logto/chrome-extension`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
- `src/content/market/batch-payload.ts`
|
||||
- `src/shared/batch-submit-client.ts`
|
||||
- `tests/batch-payload.test.ts`
|
||||
- `tests/batch-submit-client.test.ts`
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- `src/content/market/plugin-toolbar.ts`
|
||||
- `src/content/market/index.ts`
|
||||
- `src/shared/auth-messages.ts`
|
||||
- `src/background/auth/state.ts`
|
||||
- `src/popup/view.ts`
|
||||
- `scripts/mock-protected-api.mjs`
|
||||
- `tests/market-content-entry.test.ts`
|
||||
- `tests/popup-entry.test.ts`
|
||||
- `tests/mock-protected-api.test.ts`
|
||||
- `README.md`
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- `src/content/market/plugin-toolbar.ts`: add a dedicated submit button and include it in busy-state handling.
|
||||
- `src/content/market/index.ts`: orchestrate prompt input, range collection reuse, payload creation, and batch submission.
|
||||
- `src/content/market/batch-payload.ts`: create the batch payload from batch name, timestamp, auth state, and market records.
|
||||
- `src/shared/batch-submit-client.ts`: POST JSON to the protected batch endpoint with `Authorization: Bearer <token>`.
|
||||
- `src/shared/auth-messages.ts` and `src/background/auth/state.ts`: expose stable access to Logto user `sub` and display name already present in auth state.
|
||||
- `scripts/mock-protected-api.mjs`: add a local `/api/mock/batches` endpoint that validates Bearer auth and echoes the payload.
|
||||
|
||||
## Task 1: Add The Batch Payload Builder
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/batch-payload.ts`
|
||||
- Create: `tests/batch-payload.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing batch payload tests**
|
||||
|
||||
Create `tests/batch-payload.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createBatchPayload } from "../src/content/market/batch-payload";
|
||||
|
||||
describe("batch-payload", () => {
|
||||
test("builds a batch id from the batch name and timestamp", () => {
|
||||
const payload = createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: {
|
||||
name: "王少卿",
|
||||
sub: "p7pdhhtde8kj"
|
||||
}
|
||||
},
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
records: [
|
||||
{ authorId: "111", authorName: "达人A", status: "success" },
|
||||
{ authorId: "222", authorName: "达人B", status: "success" }
|
||||
]
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
authors: [
|
||||
{ authorId: "111", authorName: "达人A" },
|
||||
{ authorId: "222", authorName: "达人B" }
|
||||
],
|
||||
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when the user id is unavailable", () => {
|
||||
expect(() =>
|
||||
createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: {
|
||||
name: "王少卿"
|
||||
}
|
||||
},
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
records: [{ authorId: "111", authorName: "达人A", status: "success" }]
|
||||
})
|
||||
).toThrow(/user/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the batch payload test to verify red**
|
||||
|
||||
Run: `npm test -- tests/batch-payload.test.ts`
|
||||
|
||||
Expected: FAIL because `createBatchPayload` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal batch payload builder**
|
||||
|
||||
Create `src/content/market/batch-payload.ts` with:
|
||||
|
||||
```ts
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export interface BatchPayload {
|
||||
authors: Array<{
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
}>;
|
||||
batchId: string;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
creatorName: string;
|
||||
logtoUserId: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
export function createBatchPayload(options: {
|
||||
authState: AuthStateValue;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
records: MarketRecord[];
|
||||
}): BatchPayload {
|
||||
const logtoUserId = options.authState.userInfo?.sub?.trim();
|
||||
if (!logtoUserId) {
|
||||
throw new Error("batch submit user id unavailable");
|
||||
}
|
||||
|
||||
const resource = options.authState.resource?.trim();
|
||||
if (!resource) {
|
||||
throw new Error("batch submit resource unavailable");
|
||||
}
|
||||
|
||||
const batchName = options.batchName.trim();
|
||||
if (!batchName) {
|
||||
throw new Error("batch submit batch name is required");
|
||||
}
|
||||
|
||||
return {
|
||||
authors: options.records.map((record) => ({
|
||||
authorId: record.authorId,
|
||||
authorName: record.authorName
|
||||
})),
|
||||
batchId: `${batchName}-${options.createdAt}`,
|
||||
batchName,
|
||||
createdAt: options.createdAt,
|
||||
creatorName:
|
||||
options.authState.userInfo?.name ??
|
||||
options.authState.userInfo?.username ??
|
||||
logtoUserId,
|
||||
logtoUserId,
|
||||
resource
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the batch payload test to verify green**
|
||||
|
||||
Run: `npm test -- tests/batch-payload.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the payload builder slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/market/batch-payload.ts tests/batch-payload.test.ts
|
||||
git commit -m "feat: add batch payload builder"
|
||||
```
|
||||
|
||||
## Task 2: Build The Protected Batch Submit Client
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/batch-submit-client.ts`
|
||||
- Create: `tests/batch-submit-client.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing batch submit client tests**
|
||||
|
||||
Create `tests/batch-submit-client.test.ts` with:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
||||
|
||||
describe("batch-submit-client", () => {
|
||||
test("posts the batch payload with a Bearer token", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}));
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, acceptedCount: 2 })
|
||||
}));
|
||||
|
||||
const client = createBatchSubmitClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await client.submitBatch({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:4319/api/mock/batches",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify(
|
||||
expect.objectContaining({
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z"
|
||||
})
|
||||
),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer abc123",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
method: "POST"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws on unauthorized responses", async () => {
|
||||
const client = createBatchSubmitClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ ok: false, error: "unauthorized" })
|
||||
})),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}))
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.submitBatch({
|
||||
authors: [],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
})
|
||||
).rejects.toThrow(/unauthorized/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the batch submit client test to verify red**
|
||||
|
||||
Run: `npm test -- tests/batch-submit-client.test.ts`
|
||||
|
||||
Expected: FAIL because `createBatchSubmitClient` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal batch submit client**
|
||||
|
||||
Create `src/shared/batch-submit-client.ts` with:
|
||||
|
||||
```ts
|
||||
import { isAuthResponseMessage } from "./auth-messages";
|
||||
import type { BatchPayload } from "../content/market/batch-payload";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||
|
||||
export function createBatchSubmitClient(options: {
|
||||
baseUrl: string;
|
||||
fetchImpl?: FetchLike;
|
||||
sendMessage: SendMessageLike;
|
||||
}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
return {
|
||||
async submitBatch(payload: BatchPayload) {
|
||||
const token = await readAccessToken(options.sendMessage);
|
||||
const response = await fetchImpl(
|
||||
new URL("/api/mock/batches", options.baseUrl).toString(),
|
||||
{
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("batch submit unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`batch submit failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||
if (
|
||||
!isAuthResponseMessage(response) ||
|
||||
!response.ok ||
|
||||
response.type !== "auth:token" ||
|
||||
!response.value.accessToken.trim()
|
||||
) {
|
||||
throw new Error("batch submit token unavailable");
|
||||
}
|
||||
return response.value.accessToken;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the batch submit client test to verify green**
|
||||
|
||||
Run: `npm test -- tests/batch-submit-client.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the batch submit client slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/shared/batch-submit-client.ts tests/batch-submit-client.test.ts
|
||||
git commit -m "feat: add batch submit client"
|
||||
```
|
||||
|
||||
## Task 3: Extend The Toolbar With A Batch Submit Action
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing toolbar tests**
|
||||
|
||||
Add tests to `tests/market-content-entry.test.ts` for:
|
||||
|
||||
```ts
|
||||
test("renders a batch submit button in the toolbar", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: true,
|
||||
rates: {
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%",
|
||||
singleVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
}
|
||||
}),
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
|
||||
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
Also add a test that `setToolbarBusyState()` disables the batch submit button.
|
||||
|
||||
- [ ] **Step 2: Run the focused market toolbar tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: FAIL because the batch submit button does not exist yet
|
||||
|
||||
- [ ] **Step 3: Add the minimal toolbar button**
|
||||
|
||||
Update `src/content/market/plugin-toolbar.ts`:
|
||||
|
||||
- extend `PluginToolbarHandlers` with `onSubmitBatch()`
|
||||
- extend `PluginToolbarDom` with `batchSubmitButton: HTMLButtonElement`
|
||||
- create a new button:
|
||||
|
||||
```ts
|
||||
const batchSubmitButton = document.createElement("button");
|
||||
batchSubmitButton.type = "button";
|
||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||
batchSubmitButton.textContent = "提交批次";
|
||||
```
|
||||
|
||||
- append it next to `exportButton`
|
||||
- wire its click handler to `handlers.onSubmitBatch()`
|
||||
- include it in `readToolbarDom()` and `setToolbarBusyState()`
|
||||
|
||||
- [ ] **Step 4: Re-run the focused market toolbar tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the toolbar slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add batch submit toolbar action"
|
||||
```
|
||||
|
||||
## Task 4: Wire Batch Submission Into The Market Controller
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `src/shared/auth-messages.ts`
|
||||
- Modify: `src/background/auth/state.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing controller tests**
|
||||
|
||||
Add tests to `tests/market-content-entry.test.ts` for:
|
||||
|
||||
```ts
|
||||
test("prompts for a batch name before submitting the current range", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const prompt = vi.fn(() => "618达人筛选第一批");
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
document,
|
||||
getAuthState: async () => ({
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||
}),
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: true,
|
||||
rates: {
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%",
|
||||
singleVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
}
|
||||
}),
|
||||
promptBatchName: prompt,
|
||||
submitBatch,
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
(document.querySelector('[data-plugin-batch-submit="button"]') as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prompt).toHaveBeenCalled();
|
||||
expect(submitBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
batchName: "618达人筛选第一批",
|
||||
logtoUserId: "p7pdhhtde8kj"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("shows an error when the batch name is blank", async () => {
|
||||
// same setup, but prompt returns " "
|
||||
// expect toolbar status to contain "请输入批次名称"
|
||||
});
|
||||
|
||||
test("does nothing when the prompt is cancelled", async () => {
|
||||
// prompt returns null
|
||||
// submitBatch is not called
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused market controller tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: FAIL because batch submission wiring does not exist yet
|
||||
|
||||
- [ ] **Step 3: Add the minimal market controller wiring**
|
||||
|
||||
Update `src/content/market/index.ts`:
|
||||
|
||||
- add optional dependencies:
|
||||
|
||||
```ts
|
||||
getAuthState?: () => Promise<AuthStateValue>;
|
||||
promptBatchName?: () => string | null;
|
||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||
```
|
||||
|
||||
- default `getAuthState` to a `chrome.runtime.sendMessage({ type: "auth:get-state" })` wrapper
|
||||
- default `promptBatchName` to `() => window.prompt("请输入批次名称")`
|
||||
- default `submitBatch` to `createBatchSubmitClient(...).submitBatch`
|
||||
- add `onSubmitBatch` handler in `ensurePluginToolbar(...)`
|
||||
- behavior:
|
||||
- read target via `readToolbarExportTarget()`
|
||||
- prompt for batch name
|
||||
- cancel on `null`
|
||||
- reject blank names with `setToolbarExportStatus(toolbar, "请输入批次名称")`
|
||||
- collect records via existing `exportRecords(target)`
|
||||
- read auth state
|
||||
- build payload with `createBatchPayload(...)`
|
||||
- call `submitBatch(payload)`
|
||||
- show `批次提交成功` on success
|
||||
- show thrown message or `批次提交失败,请稍后重试` on failure
|
||||
|
||||
- [ ] **Step 4: Extend auth state only if the tests expose a gap**
|
||||
|
||||
If tests reveal missing fields, ensure `src/background/auth/state.ts` continues exposing:
|
||||
- `userInfo.sub`
|
||||
- `userInfo.name`
|
||||
- `resource`
|
||||
|
||||
If `src/shared/auth-messages.ts` needs typing support, update only types, not message kinds.
|
||||
|
||||
- [ ] **Step 5: Re-run the focused market controller tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the market controller slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts src/shared/auth-messages.ts src/background/auth/state.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: wire market batch submission flow"
|
||||
```
|
||||
|
||||
## Task 5: Extend The Mock Server And Popup Debugging
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/mock-protected-api.mjs`
|
||||
- Modify: `tests/mock-protected-api.test.ts`
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `tests/popup-entry.test.ts` only if needed for debug output wording
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing mock batch endpoint tests**
|
||||
|
||||
Add cases to `tests/mock-protected-api.test.ts`:
|
||||
|
||||
```ts
|
||||
test("accepts a batch payload when a Bearer token is present", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
|
||||
body: JSON.stringify({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
}),
|
||||
headers: {
|
||||
Authorization: "Bearer abc123",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
acceptedCount: 1,
|
||||
ok: true,
|
||||
source: "mock-batch-submit"
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the mock server tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: FAIL because the batch endpoint does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal mock batch endpoint**
|
||||
|
||||
Update `scripts/mock-protected-api.mjs`:
|
||||
|
||||
- keep the existing `/api/mock/protected`
|
||||
- add `/api/mock/batches` for `POST`
|
||||
- require the same Bearer header validation
|
||||
- read the JSON body and return:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"source": "mock-batch-submit",
|
||||
"acceptedCount": 1,
|
||||
"batchId": "<echoed batch id>"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the docs**
|
||||
|
||||
Add to `README.md`:
|
||||
- how to trigger `提交批次`
|
||||
- how the prompt should be filled
|
||||
- expected mock response
|
||||
|
||||
- [ ] **Step 5: Re-run the mock server tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the mock batch slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add scripts/mock-protected-api.mjs tests/mock-protected-api.test.ts README.md
|
||||
git commit -m "feat: add mock batch submit endpoint"
|
||||
```
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] Run the focused batch feature tests:
|
||||
|
||||
```bash
|
||||
npm test -- tests/auth-config.test.ts tests/batch-payload.test.ts tests/batch-submit-client.test.ts tests/mock-protected-api.test.ts tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] Run the full test suite:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] Run the production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build completes and updates `dist/`
|
||||
|
||||
- [ ] Perform the manual smoke test:
|
||||
|
||||
1. Start `npm run mock:protected-api`
|
||||
2. Reload the unpacked extension from `dist/`
|
||||
3. Log in through the popup
|
||||
4. Open the Xingtu market page
|
||||
5. Choose an export range
|
||||
6. Click `提交批次`
|
||||
7. Enter a batch name in `prompt()`
|
||||
8. Confirm success text appears
|
||||
9. Verify the mock response contains the batch id and accepted creator count
|
||||
@@ -0,0 +1,257 @@
|
||||
# 星图市场导出范围设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前插件的 `导出CSV` 只导出当前页数据,并在星图默认列后追加两列:
|
||||
|
||||
- `单视频看后搜率`
|
||||
- `个人视频看后搜率`
|
||||
|
||||
现阶段用户希望把导出范围扩展为可配置页数,而不是固定当前页。同时需要保留当前默认列导出逻辑,避免重新引入之前“全量扫描导致页面闪动、卡顿、排序异常”的问题。
|
||||
|
||||
## 目标
|
||||
|
||||
- 支持导出范围配置,默认导出前 `5` 页。
|
||||
- 支持以下范围选项:
|
||||
- `当前页`
|
||||
- `前5页`
|
||||
- `前10页`
|
||||
- `全部`
|
||||
- `自定义`
|
||||
- 保留当前 CSV 规则:
|
||||
- 先导出星图页面默认列
|
||||
- 末尾追加两列插件字段
|
||||
- 批量导出时真实翻页采集当前筛选结果页,不改变当前页面筛选条件来源。
|
||||
- 保持当前“筛选/排序只作用当前页”的行为,不重新绑定多页扫描逻辑。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改为直接调用星图列表接口拉多页数据。
|
||||
- 不让插件筛选或排序自动跨页生效。
|
||||
- 不处理“导出后自动回到最初分页位置”的复杂恢复,首版只要求滚动位置恢复到单页采集前。
|
||||
|
||||
## 交互设计
|
||||
|
||||
在插件工具栏中保留一个 `导出CSV` 按钮,同时新增导出范围控件。
|
||||
|
||||
### 范围控件
|
||||
|
||||
- 默认值:`前5页`
|
||||
- 可选项:
|
||||
- `当前页`
|
||||
- `前5页`
|
||||
- `前10页`
|
||||
- `全部`
|
||||
- `自定义`
|
||||
- 选择 `自定义` 时,展示页数输入框
|
||||
- 仅接受正整数
|
||||
- 无效值时禁止导出并提示错误
|
||||
|
||||
### 任务状态
|
||||
|
||||
- 导出开始后:
|
||||
- `导出CSV` 按钮禁用
|
||||
- 筛选按钮、排序按钮、范围控件、自定义输入框一并禁用
|
||||
- 按钮文案根据任务模式更新:
|
||||
- 当前页:`导出中...`
|
||||
- 前 N 页:`导出中 3/5 页...`
|
||||
- 全部:`导出中 第3页...`
|
||||
- 全部导出时额外显示轻提示:
|
||||
- `全部导出可能耗时较长,页面会自动翻页。`
|
||||
|
||||
### 失败反馈
|
||||
|
||||
- 任一页采集失败时直接终止
|
||||
- 通过按钮附近状态文本或 `alert` 提示:
|
||||
- `第 N 页导出失败,请稍后重试`
|
||||
- 失败时不下载残缺 CSV
|
||||
|
||||
## 执行流程
|
||||
|
||||
### 当前页导出
|
||||
|
||||
当前页模式沿用现有逻辑:
|
||||
|
||||
1. 等待当前页表格可读取
|
||||
2. 自动滚动当前页一次,补齐懒加载字段
|
||||
3. 读取当前页星图默认列
|
||||
4. 合并插件两列
|
||||
5. 生成 CSV 并下载
|
||||
|
||||
### 多页导出
|
||||
|
||||
多页模式仅在导出任务中启用,不影响平时筛选和排序。
|
||||
|
||||
1. 读取导出范围配置
|
||||
2. 初始化批量任务状态与结果聚合器
|
||||
3. 对当前页执行:
|
||||
- 等待表格稳定
|
||||
- 自动滚动当前页以采全默认列
|
||||
- 读取当前页记录
|
||||
- 以 `authorId` 合并入结果集
|
||||
4. 判断是否达到目标页数
|
||||
5. 若未完成,则点击下一页
|
||||
6. 等待“页切换完成”信号
|
||||
7. 重复步骤 3 到 6
|
||||
8. 汇总所有记录,生成单个 CSV 下载
|
||||
|
||||
### 全部导出停止条件
|
||||
|
||||
`全部` 模式从当前页开始向后翻页,直到任一条件成立:
|
||||
|
||||
- 下一页按钮不可点击
|
||||
- 当前页签名不再变化
|
||||
- 页面无法继续翻页并达到超时阈值
|
||||
|
||||
## 页切换判定
|
||||
|
||||
批量导出不能只依赖固定 `setTimeout`。翻页完成需要同时满足至少一个强信号:
|
||||
|
||||
- 当前页作者 ID 集合发生变化
|
||||
- 当前页首行作者 ID 变化
|
||||
- 当前页活动分页按钮变化
|
||||
- 下一页按钮状态变化
|
||||
|
||||
为避免误判,翻页流程需要:
|
||||
|
||||
1. 记录翻页前页签名
|
||||
2. 点击下一页
|
||||
3. 轮询直到签名变化或超时
|
||||
4. 签名变化后再执行一次当前页表格稳定等待
|
||||
|
||||
## 当前页采集策略
|
||||
|
||||
当前页导出已知存在懒加载问题:页面初加载时后半段行的默认列为空,滚动后才补齐。
|
||||
|
||||
因此无论当前页导出还是多页导出,每一页都必须执行:
|
||||
|
||||
1. 初次读取当前页快照并写入 store
|
||||
2. 查找当前市场列表滚动容器
|
||||
3. 逐步向下滚动直到该页底部
|
||||
4. 每次滚动后等待 DOM 稳定,再采集一轮
|
||||
5. 恢复滚动位置并再采集一轮
|
||||
|
||||
这一步只针对“当前已打开的页”,不跨页扫描。
|
||||
|
||||
## 数据合并规则
|
||||
|
||||
### 去重键
|
||||
|
||||
- 使用 `authorId` 去重
|
||||
|
||||
### 字段合并
|
||||
|
||||
- 新页记录有值时,补充到已有记录
|
||||
- 新页记录为空时,不覆盖已有非空值
|
||||
- 插件两列沿用当前已有的合并规则
|
||||
|
||||
### CSV 表头
|
||||
|
||||
- 先保留星图默认列顺序
|
||||
- 再追加:
|
||||
- `单视频看后搜率`
|
||||
- `个人视频看后搜率`
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 单页失败
|
||||
|
||||
以下情况视为单页失败:
|
||||
|
||||
- 找不到表格根节点
|
||||
- 找不到下一页按钮但目标尚未完成
|
||||
- 翻页后页签名未变化且超时
|
||||
- 当前页采集过程中出现异常
|
||||
|
||||
处理方式:
|
||||
|
||||
- 立即终止任务
|
||||
- 恢复工具栏控件状态
|
||||
- 显示页级错误信息
|
||||
- 不触发下载
|
||||
|
||||
### 用户干预
|
||||
|
||||
首版不额外提供取消按钮,但应防御以下情况:
|
||||
|
||||
- 用户手动切换筛选
|
||||
- 用户手动翻页
|
||||
- 用户再次点击导出
|
||||
|
||||
处理方式:
|
||||
|
||||
- 导出期间禁用插件控件
|
||||
- 对关键节点重新校验表格和页签名
|
||||
- 如环境已被破坏,终止任务并报错
|
||||
|
||||
## 实现建议
|
||||
|
||||
建议按以下边界组织实现:
|
||||
|
||||
### `plugin-toolbar`
|
||||
|
||||
- 新增导出范围选择器
|
||||
- 新增自定义页数输入框
|
||||
- 新增导出过程中的状态展示
|
||||
|
||||
### `market/index`
|
||||
|
||||
- 保留当前页导出逻辑作为单页模式
|
||||
- 新增批量导出入口
|
||||
- 管理任务状态、按钮禁用、进度回写
|
||||
|
||||
### 新建批量导出控制器
|
||||
|
||||
建议新增类似 `export-range-controller.ts` 的模块,职责包括:
|
||||
|
||||
- 读取当前页记录
|
||||
- 控制翻页
|
||||
- 等待页切换
|
||||
- 聚合多页数据
|
||||
- 统一返回最终 `MarketRecord[]`
|
||||
|
||||
这样可以避免把 `market/index.ts` 继续堆大。
|
||||
|
||||
### `dom-sync` 或新辅助模块
|
||||
|
||||
- 提供读取当前页签名
|
||||
- 提供定位下一页按钮
|
||||
- 提供判断下一页是否可点击
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 默认导出范围为前 5 页
|
||||
- 选择 `当前页` 时只导出一页
|
||||
- 选择 `前10页` 时最多导出 10 页
|
||||
- `自定义` 输入非法值时不启动导出
|
||||
- `全部` 模式在没有下一页时停止
|
||||
- 多页合并时按 `authorId` 去重
|
||||
- 空字段不会覆盖已有非空字段
|
||||
|
||||
### 集成测试
|
||||
|
||||
- 批量导出时会依次点击下一页
|
||||
- 翻页后等待新页签名再采集
|
||||
- 每一页在导出前都会执行滚动采集
|
||||
- 中途某页失败时不生成 CSV
|
||||
- 导出期间工具栏控件被禁用
|
||||
|
||||
## 风险
|
||||
|
||||
- 星图分页 DOM 结构可能不稳定,下一页按钮定位需要做降级兼容。
|
||||
- 全部导出在高页数场景下耗时会较长。
|
||||
- 页面中途刷新或筛选重算可能打断批量任务。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 工具栏新增范围选择与自定义输入
|
||||
2. 抽出批量导出控制器
|
||||
3. 实现前 N 页导出
|
||||
4. 在前 N 页能力稳定后开放 `全部`
|
||||
5. 补齐全部相关失败与超时测试
|
||||
|
||||
## 决策结论
|
||||
|
||||
采用“一个导出按钮 + 范围选择器”的方案,默认导出前 `5` 页,支持 `全部` 和 `自定义`。批量模式通过真实翻页逐页采集当前筛选结果,不把多页扫描重新引入到筛选和排序链路中。
|
||||
@@ -0,0 +1,216 @@
|
||||
# Logto 受保护 API Mock 联调设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前扩展已经具备基础的 Logto 登录能力:
|
||||
|
||||
- popup 可触发登录和登出
|
||||
- background 可通过 `@logto/chrome-extension` 获取 access token
|
||||
- 页面功能在未登录时会被 auth gate 拦住
|
||||
|
||||
但现阶段业务数据仍然来自页面站内接口,尚未真正验证“扩展拿到 Logto access token 后,请求受保护 API”这条链路。后续真实后端将由其他人提供,因此当前阶段的目标不是接入正式接口,而是先在本地完成一套可重复验证的模拟联调方案。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增一个专用于 Logto 受保护 API 的扩展客户端。
|
||||
- 扩展请求受保护 API 前,必须先通过 background 获取 access token。
|
||||
- 请求时自动附加 `Authorization: Bearer <token>` 请求头。
|
||||
- 提供一个本地 mock 受保护 API,用于验证扩展到后端的完整调用链路。
|
||||
- 提供自动化测试,分别覆盖:
|
||||
- token 注入逻辑
|
||||
- mock API 授权成功/失败行为
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不接入真实业务后端。
|
||||
- 不做真实 JWT 签名校验、JWKS 拉取或资源权限判定。
|
||||
- 不修改现有星图页面数据采集逻辑为正式后端模式。
|
||||
- 不在本阶段设计复杂的 token 刷新监控或重试策略。
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案 A:只做本地 mock 后端
|
||||
|
||||
- 直接搭一个假接口,扩展请求它并观察返回。
|
||||
- 优点:最接近最终使用方式。
|
||||
- 缺点:如果联调失败,不容易快速判断问题出在 token 注入还是 mock 服务本身。
|
||||
|
||||
### 方案 B:只做代码级测试
|
||||
|
||||
- 不起本地服务,只用测试替身验证请求头是否带 token。
|
||||
- 优点:实现快,定位问题直接。
|
||||
- 缺点:无法证明完整链路可运行。
|
||||
|
||||
### 方案 C:先代码级测试,再接本地 mock 后端
|
||||
|
||||
- 先用单元测试锁定 token 注入行为,再起 mock 服务完成真实联调。
|
||||
- 优点:定位问题更快,同时保留完整链路验证。
|
||||
- 缺点:改动稍多于单一方案。
|
||||
|
||||
推荐采用方案 C。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 1. 扩展受保护 API 客户端
|
||||
|
||||
新增一个独立客户端模块,职责如下:
|
||||
|
||||
- 向 background 发送 `auth:get-access-token` 消息
|
||||
- 读取返回的 access token
|
||||
- 使用该 token 请求指定后端地址
|
||||
- 将接口成功、未授权、网络失败这三类结果转成明确错误
|
||||
|
||||
这个客户端不直接耦合星图 DOM,也不依赖 popup。它只负责“拿 token 并带 token 发请求”,这样后续替换正式后端时只需要调整接口地址和返回映射。
|
||||
|
||||
### 2. Background 认证桥接
|
||||
|
||||
现有 background 已支持 `auth:get-access-token`。本次不改变登录主流程,只把它当作唯一 token 来源:
|
||||
|
||||
- content script 不直接接触 Logto SDK
|
||||
- 所有受保护 API 请求都通过 background 提供 token
|
||||
|
||||
这样可以保持认证逻辑集中,符合 MV3 扩展的边界约束。
|
||||
|
||||
### 3. 本地 mock 受保护 API
|
||||
|
||||
新增一个轻量本地服务作为测试后端,建议职责保持极小:
|
||||
|
||||
- 暴露固定测试 endpoint,例如 `/api/mock/protected`
|
||||
- 检查请求头中是否存在 `Authorization`
|
||||
- 如果请求头形如 `Bearer <非空字符串>`,返回固定假数据
|
||||
- 如果没有该头,返回 `401`
|
||||
|
||||
本阶段不要求验证 token 是否来自真实 Logto,只验证“扩展是否按约定附带了 Bearer token”。
|
||||
|
||||
## 数据流
|
||||
|
||||
完整调用链路如下:
|
||||
|
||||
1. 扩展中的业务入口调用受保护 API 客户端
|
||||
2. 客户端向 background 发送 `auth:get-access-token`
|
||||
3. background 返回当前 access token
|
||||
4. 客户端带上 `Authorization: Bearer <token>` 请求本地 mock API
|
||||
5. mock API 校验请求头并返回假数据
|
||||
6. 客户端将结果回传给调用方
|
||||
|
||||
失败分支:
|
||||
|
||||
- 如果 background 没返回合法 token,客户端直接报错,不发请求
|
||||
- 如果 mock API 返回 `401`,客户端将其识别为未授权错误
|
||||
- 如果请求超时或网络失败,客户端抛出网络错误
|
||||
|
||||
## 接口设计
|
||||
|
||||
### 扩展内客户端接口
|
||||
|
||||
建议客户端提供单一入口,例如:
|
||||
|
||||
- `loadProtectedMockData()`
|
||||
|
||||
内部行为:
|
||||
|
||||
- 先取 token
|
||||
- 再发 GET 请求
|
||||
- 返回结构化响应对象或抛出明确错误
|
||||
|
||||
后续替换真实后端时,可以保留同样入口,内部再切换到真实 endpoint。
|
||||
|
||||
### Mock API 返回
|
||||
|
||||
成功时返回固定 JSON,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"source": "mock-protected-api",
|
||||
"message": "authorized",
|
||||
"receivedAuthHeader": "Bearer ..."
|
||||
}
|
||||
```
|
||||
|
||||
失败时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 无 token
|
||||
|
||||
- 判定条件:background 未返回 `auth:token`,或 token 为空字符串
|
||||
- 处理方式:立即抛错,提示当前未登录或 token 不可用
|
||||
|
||||
### 未授权
|
||||
|
||||
- 判定条件:后端返回 `401` 或 `403`
|
||||
- 处理方式:抛出授权失败错误,供上层展示“请重新登录”
|
||||
|
||||
### 网络失败
|
||||
|
||||
- 判定条件:fetch 抛异常、连接失败、超时
|
||||
- 处理方式:抛出网络错误,不吞掉异常原因
|
||||
|
||||
## 实现边界
|
||||
|
||||
建议新增或调整如下模块:
|
||||
|
||||
### `src/content/market` 下新增受保护 API 客户端
|
||||
|
||||
- 不替换现有页面接口客户端
|
||||
- 独立处理 mock 受保护 API 访问
|
||||
- 便于后续把“页面抓取模式”和“后端接口模式”并行保留
|
||||
|
||||
### `src/shared/auth-messages.ts`
|
||||
|
||||
- 复用现有 `auth:get-access-token`
|
||||
- 若现有消息结构足够,则不新增消息类型
|
||||
|
||||
### `scripts/` 或独立目录中的 mock 服务
|
||||
|
||||
- 提供本地测试服务启动脚本
|
||||
- 默认监听本地固定端口
|
||||
- 返回固定 JSON 结果
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 客户端请求前会先获取 access token
|
||||
- 拿到 token 后,请求头中包含 `Authorization: Bearer <token>`
|
||||
- token 缺失时不会发起 fetch
|
||||
- 接口返回 `401` 时抛出未授权错误
|
||||
- 接口返回成功时正确解析 JSON
|
||||
|
||||
### 联调测试
|
||||
|
||||
- 启动本地 mock 服务后,带 token 请求能成功
|
||||
- 不带 token 请求返回 `401`
|
||||
- 扩展客户端能读到 mock 返回的假数据
|
||||
|
||||
## 手动验证
|
||||
|
||||
1. 构建并加载扩展
|
||||
2. 在 popup 中完成 Logto 登录
|
||||
3. 启动本地 mock API
|
||||
4. 触发扩展中的受保护接口请求
|
||||
5. 确认 mock API 收到 `Authorization: Bearer <token>`
|
||||
6. 确认扩展端收到成功响应
|
||||
|
||||
## 迁移到真实后端的路径
|
||||
|
||||
当真实后端可用时,仅需要替换以下内容:
|
||||
|
||||
- mock API 基地址
|
||||
- 具体 endpoint 路径
|
||||
- 返回数据结构映射
|
||||
- 若真实后端要求额外 scope,则补充 `auth-config` 中的 scopes
|
||||
|
||||
核心认证链路保持不变:
|
||||
|
||||
- 仍由 background 提供 token
|
||||
- 仍由独立客户端附带 `Bearer` 请求头
|
||||
- 仍按未授权和网络错误分别处理
|
||||
@@ -0,0 +1,110 @@
|
||||
# Market Backend Metrics Design
|
||||
|
||||
## Goal
|
||||
|
||||
Connect the current Chrome extension to the real backend search API and display six backend metrics in the plugin UI, while preserving the existing two Xingtu rate columns.
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
- Keep the existing two Xingtu metrics unchanged:
|
||||
- `singleVideoAfterSearchRate`
|
||||
- `personalVideoAfterSearchRate`
|
||||
- Add one new UI column named `秒探指标`.
|
||||
- Render the six backend metrics inside that column as a compact 2-column metrics panel:
|
||||
- `avg_after_view_search_rate`
|
||||
- `avg_after_view_search_cnt`
|
||||
- `avg_a3_increase_cnt`
|
||||
- `avg_new_a3_rate`
|
||||
- `cpa3`
|
||||
- `cp_search`
|
||||
- Fetch backend metrics through the background service worker, not directly from the content script.
|
||||
- Use Logto access tokens from the existing background auth flow.
|
||||
- Query the backend by `star_id`, not by nickname.
|
||||
- Send one batched request per visible page of creators.
|
||||
- Use a code-level default backend base URL. Do not build a settings UI in this change.
|
||||
- Do not change CSV export or batch submission payloads in this change.
|
||||
|
||||
## API Contract
|
||||
|
||||
- Base URL:
|
||||
- default to `http://192.168.31.29:8083`
|
||||
- Endpoint:
|
||||
- `POST /api/v1/history/talents/search`
|
||||
- Headers:
|
||||
- `Authorization: Bearer <Logto access token>`
|
||||
- `Content-Type: application/json`
|
||||
- Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "star_id",
|
||||
"values": ["7252982749131178039", "7290491710910496809"],
|
||||
"page": 1,
|
||||
"size": 100
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Background
|
||||
|
||||
- Add a new background message for backend metrics search.
|
||||
- The background handler:
|
||||
- gets the current Logto access token
|
||||
- calls the backend search API
|
||||
- returns parsed metric rows to the content script
|
||||
|
||||
This follows the same rule already used for protected API and batch submission: token-bearing business requests belong in the background layer.
|
||||
|
||||
### Content
|
||||
|
||||
- Replace per-row metric loading with page-level batched loading for backend metrics.
|
||||
- Collect all visible `authorId` values from the current page.
|
||||
- Send them to the background as `star_id[]`.
|
||||
- Map the backend response back to rows by `star_id`.
|
||||
|
||||
### UI
|
||||
|
||||
- Keep the old two columns where they are today.
|
||||
- Add one new `秒探指标` column.
|
||||
- Each cell shows:
|
||||
- `加载中...`
|
||||
- a compact 6-metric panel
|
||||
- `暂无数据`
|
||||
- `加载失败`
|
||||
|
||||
## Data Model
|
||||
|
||||
Add a new backend metrics structure to market records:
|
||||
|
||||
- `afterViewSearchRate`
|
||||
- `afterViewSearchCount`
|
||||
- `a3IncreaseCount`
|
||||
- `newA3Rate`
|
||||
- `cpa3`
|
||||
- `cpSearch`
|
||||
|
||||
The old Xingtu rates stay in the existing `rates` structure.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- No token: show `加载失败`
|
||||
- Backend request failure: show `加载失败`
|
||||
- Backend success but row missing in response: show `暂无数据`
|
||||
- Partial page matches: render matched rows, mark unmatched rows as `暂无数据`
|
||||
|
||||
## Testing
|
||||
|
||||
- Add failing tests first for:
|
||||
- backend metrics request message handling
|
||||
- backend metrics client request/response mapping
|
||||
- page-level batch loading by `star_id`
|
||||
- UI rendering of the new `秒探指标` column for loading, success, missing, and failure states
|
||||
- Run focused tests first, then full `npm test`, then `npm run build`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Popup-based backend URL settings
|
||||
- CSV export changes for the six backend metrics
|
||||
- Batch submit payload changes
|
||||
- Cross-page deduplicated backend caching beyond current in-memory record storage
|
||||
@@ -0,0 +1,305 @@
|
||||
# 星图达人批次提交设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前插件已经具备以下能力:
|
||||
|
||||
- 使用 Logto 登录并获取访问 `https://talent-search.intelligrow.cn` 的 access token
|
||||
- 读取星图市场页中的达人列表数据
|
||||
- 支持按当前页、前 5 页、前 10 页、全部、自定义页数进行多页采集
|
||||
- 支持导出 CSV
|
||||
|
||||
下一阶段需要把“当前采集范围内的一批达人”提交给其他同学维护的后端接口,而不是逐条发送。用户期望在现有导出范围逻辑基础上,新增一个独立的“提交批次”动作,用来测试整批提交流程。
|
||||
|
||||
## 目标
|
||||
|
||||
- 在现有工具栏中新增一个独立的 `提交批次` 按钮
|
||||
- 提交动作复用当前导出范围逻辑,支持多页达人数据采集
|
||||
- 提交前通过浏览器 `prompt()` 让用户输入批次名称
|
||||
- 自动生成 `batchId = 批次名称 + 时间戳`
|
||||
- 将以下信息整合为单个批次 payload 发送给后端:
|
||||
- Logto 用户 ID
|
||||
- 星图达人 ID 列表
|
||||
- 用户输入的批次名称
|
||||
- 自动生成的批次 ID
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改造为逐条提交达人
|
||||
- 不移除或替代现有 `导出 CSV`
|
||||
- 不在首版实现复杂弹窗或表单 UI
|
||||
- 不在首版实现批次列表管理、重试历史、草稿保存
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案 A:只提交当前页
|
||||
|
||||
- 新按钮只处理当前页达人
|
||||
- 优点:实现最简单
|
||||
- 缺点:与现有多页导出/采集使用方式不一致,不符合真实业务需求
|
||||
|
||||
### 方案 B:提交当前导出范围内的一整批达人
|
||||
|
||||
- 复用现有导出范围控制器,提交当前选择范围内的所有达人
|
||||
- 优点:与用户当前使用习惯一致,便于从测试过渡到真实业务
|
||||
- 缺点:比只提交当前页多一层批次组装逻辑
|
||||
|
||||
### 方案 C:先导出 CSV,再从 CSV 结果提交
|
||||
|
||||
- 先走导出逻辑,再把导出结果转换为提交 payload
|
||||
- 优点:数据来源统一
|
||||
- 缺点:流程绕、耦合高,不适合作为长期模式
|
||||
|
||||
推荐采用方案 B。
|
||||
|
||||
## 交互设计
|
||||
|
||||
### 工具栏按钮
|
||||
|
||||
在现有工具栏中保留 `导出 CSV`,并新增:
|
||||
|
||||
- `提交批次`
|
||||
|
||||
两个按钮共享现有导出范围控件:
|
||||
|
||||
- 当前页
|
||||
- 前 5 页
|
||||
- 前 10 页
|
||||
- 全部
|
||||
- 自定义
|
||||
|
||||
### 批次名称输入
|
||||
|
||||
用户点击 `提交批次` 时:
|
||||
|
||||
1. 弹出浏览器 `prompt()`
|
||||
2. 提示文案建议为:`请输入批次名称`
|
||||
3. 用户输入如:`618 达人筛选第一批`
|
||||
|
||||
处理规则:
|
||||
|
||||
- 用户取消:直接终止,不报错
|
||||
- 输入为空或仅空格:提示 `请输入批次名称`
|
||||
- 输入有效:继续批次提交流程
|
||||
|
||||
### 提交状态
|
||||
|
||||
批次提交过程中:
|
||||
|
||||
- `提交批次` 按钮禁用
|
||||
- `导出 CSV` 按钮与范围选择器一并禁用
|
||||
- 状态文案可复用现有导出状态区域
|
||||
|
||||
建议状态文案:
|
||||
|
||||
- 当前页:`提交中...`
|
||||
- 前 N 页:`提交中 3/5 页...`
|
||||
- 全部:`提交中 第 3 页...`
|
||||
|
||||
### 成功与失败反馈
|
||||
|
||||
成功:
|
||||
|
||||
- 提示 `批次提交成功`
|
||||
|
||||
失败:
|
||||
|
||||
- 未登录:`请先登录插件`
|
||||
- 批次名为空:`请输入批次名称`
|
||||
- 接口失败:`批次提交失败,请稍后重试`
|
||||
|
||||
## 批次数据模型
|
||||
|
||||
首版建议 payload 结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"logtoUserId": "p7pdhhtde8kj",
|
||||
"creatorName": "王少卿",
|
||||
"resource": "https://talent-search.intelligrow.cn",
|
||||
"batchName": "618达人筛选第一批",
|
||||
"batchId": "618达人筛选第一批-2026-04-22T12:30:00.000Z",
|
||||
"createdAt": "2026-04-22T12:30:00.000Z",
|
||||
"authors": [
|
||||
{
|
||||
"authorId": "111",
|
||||
"authorName": "达人A"
|
||||
},
|
||||
{
|
||||
"authorId": "222",
|
||||
"authorName": "达人B"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段来源
|
||||
|
||||
- `logtoUserId`
|
||||
- 来源:当前登录用户的 Logto `sub`
|
||||
- `creatorName`
|
||||
- 来源:当前登录用户 `name` 或 `username`
|
||||
- `resource`
|
||||
- 来源:当前认证配置中的 API resource
|
||||
- `batchName`
|
||||
- 来源:用户在 `prompt()` 中输入
|
||||
- `createdAt`
|
||||
- 来源:当前时间的 ISO 字符串
|
||||
- `batchId`
|
||||
- 规则:`${batchName}-${createdAt}`
|
||||
- `authors`
|
||||
- 来源:当前导出范围内采集出的达人记录
|
||||
|
||||
### 达人字段
|
||||
|
||||
首版只要求最小字段:
|
||||
|
||||
- `authorId`
|
||||
- `authorName`
|
||||
|
||||
后续如有需要,可增加:
|
||||
|
||||
- `price21To60s`
|
||||
- 看后搜率字段
|
||||
- 城市/地区等补充信息
|
||||
|
||||
## 数据流
|
||||
|
||||
完整流程如下:
|
||||
|
||||
1. 用户在工具栏选择导出范围
|
||||
2. 用户点击 `提交批次`
|
||||
3. 插件弹出 `prompt()` 请求输入批次名称
|
||||
4. 插件检查登录状态与用户信息
|
||||
5. 插件复用当前多页采集逻辑,获取当前范围内达人记录
|
||||
6. 插件读取 Logto 用户 ID
|
||||
7. 插件生成 `createdAt` 和 `batchId`
|
||||
8. 插件组装整批 payload
|
||||
9. 插件使用带 Bearer token 的客户端提交到后端
|
||||
10. 插件显示成功或失败状态
|
||||
|
||||
## 模块边界
|
||||
|
||||
建议按以下边界实现:
|
||||
|
||||
### 工具栏层
|
||||
|
||||
职责:
|
||||
|
||||
- 渲染 `提交批次` 按钮
|
||||
- 管理按钮禁用态
|
||||
- 回传点击事件
|
||||
|
||||
### 市场控制器层
|
||||
|
||||
职责:
|
||||
|
||||
- 响应 `提交批次` 点击
|
||||
- 调用 `prompt()` 获取批次名称
|
||||
- 复用现有导出范围采集逻辑
|
||||
- 调用批次 payload 组装器和提交客户端
|
||||
|
||||
### 批次 payload 组装层
|
||||
|
||||
职责:
|
||||
|
||||
- 接收用户信息、批次名、时间戳、达人记录
|
||||
- 生成标准批次对象
|
||||
|
||||
该层不负责网络请求,方便后续根据后端要求调整结构。
|
||||
|
||||
### 批次提交客户端
|
||||
|
||||
职责:
|
||||
|
||||
- 获取 access token
|
||||
- 附带 `Authorization: Bearer <token>` 请求头
|
||||
- 向后端提交整批 JSON
|
||||
|
||||
后续从 mock 接口切到真实接口时,应主要修改这一层。
|
||||
|
||||
## 认证与授权
|
||||
|
||||
当前系统已具备:
|
||||
|
||||
- Logto 登录能力
|
||||
- 请求 `https://talent-search.intelligrow.cn` resource 的 access token
|
||||
- `talent-search:read` scope
|
||||
|
||||
首版批次提交如果是写操作,后端后续可能需要新增写权限,例如:
|
||||
|
||||
- `talent-search:write`
|
||||
- 或 `talent-search:batch:submit`
|
||||
|
||||
如果后端要求新的写权限,则前端需要同步更新 `auth-config.ts` 中的 `scopes`。
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 用户取消输入
|
||||
|
||||
- 直接停止,不提示错误
|
||||
|
||||
### 批次名为空
|
||||
|
||||
- 不发请求
|
||||
- 直接提示 `请输入批次名称`
|
||||
|
||||
### 未登录或取不到用户信息
|
||||
|
||||
- 不发请求
|
||||
- 提示 `请先登录插件`
|
||||
|
||||
### 采集失败
|
||||
|
||||
- 复用当前导出多页采集失败逻辑
|
||||
- 不生成不完整 payload
|
||||
|
||||
### 接口失败
|
||||
|
||||
- 提示 `批次提交失败,请稍后重试`
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 点击 `提交批次` 时会弹出 `prompt()`
|
||||
- 用户取消输入时不会继续提交
|
||||
- 空白批次名会报错
|
||||
- `batchId` 按 `批次名 + ISO 时间戳` 生成
|
||||
- payload 中包含:
|
||||
- `logtoUserId`
|
||||
- `batchName`
|
||||
- `batchId`
|
||||
- `authors[]`
|
||||
|
||||
### 集成测试
|
||||
|
||||
- `提交批次` 复用当前导出范围采集逻辑
|
||||
- 当前页模式只提交当前页达人
|
||||
- 多页模式会合并多页达人
|
||||
- 接口调用时带 Bearer token
|
||||
- 成功时显示成功状态
|
||||
- 失败时显示失败状态
|
||||
|
||||
## 手动验证
|
||||
|
||||
1. 登录插件
|
||||
2. 选择导出范围
|
||||
3. 点击 `提交批次`
|
||||
4. 在 `prompt()` 中输入批次名
|
||||
5. 确认请求 payload 中包含:
|
||||
- Logto 用户 ID
|
||||
- 多个达人 ID
|
||||
- 批次名
|
||||
- 批次 ID
|
||||
6. 确认后端返回成功
|
||||
|
||||
## 后续扩展
|
||||
|
||||
当真实接口稳定后,可以继续扩展:
|
||||
|
||||
- 批次提交结果详情展示
|
||||
- 最近提交批次列表
|
||||
- 批次重试
|
||||
- 批次备注、标签
|
||||
- 写权限 scope 单独拆分
|
||||
@@ -0,0 +1,466 @@
|
||||
# Logto 登录集成设计
|
||||
|
||||
日期:2026-04-21
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前项目是一个 Chrome MV3 扩展,主要运行在星图市场页面,通过内容脚本注入页面并提供筛选、排序、导出等增强能力。
|
||||
|
||||
下一阶段需要为插件补齐登录能力,接入 Logto,目标是:
|
||||
|
||||
- 让插件具备统一的登录态
|
||||
- 登录后可为未来的业务后端 API 获取 access token
|
||||
- 未登录时禁止使用当前插件能力
|
||||
- 这一版先打通登录、登出、会话持久化和 token 获取链路,不接真实业务后端
|
||||
|
||||
## 2. 已确认的产品决策
|
||||
|
||||
- 登录入口放在扩展 `popup`
|
||||
- 目标范围是“插件登录态 + 为未来后端 API 预留 access token 获取能力”
|
||||
- 当前版本暂不接真实后端 API
|
||||
- 未登录时,插件功能整体禁用
|
||||
- 仅允许内部已有 Logto 账号成员登录,不开放注册
|
||||
- `popup` 默认显示简洁账号信息,开发模式下显示调试信息
|
||||
- 未登录时,市场页禁用面板需要提供“去登录”引导
|
||||
- 配置项先使用占位值,不在设计文档里写死真实 tenant / appId / resource
|
||||
- 设计文档明确建议新增目录结构,而不是只写抽象功能
|
||||
|
||||
## 3. 官方文档约束
|
||||
|
||||
本设计基于以下 Logto 官方文档:
|
||||
|
||||
- Chrome 扩展快速开始:<https://docs.logto.io/quick-starts/chrome-extension>
|
||||
- Global API resources:<https://docs.logto.io/zh-CN/authorization/global-api-resources>
|
||||
|
||||
从官方文档抽取的关键约束如下:
|
||||
|
||||
- Chrome 扩展应使用适配扩展场景的登录流程,重定向 URI 基于 `chrome.identity.getRedirectURL()`
|
||||
- Manifest 需要增加 `identity`、`storage` 等权限
|
||||
- 登录流程更适合由后台脚本统一承接,而不是由内容脚本直接执行
|
||||
- access token 应面向一个明确的 API resource,而不是把“登录成功”与“可访问后端 API”混为一谈
|
||||
|
||||
这些约束直接决定了当前项目不能把认证逻辑塞进现有内容脚本入口。
|
||||
|
||||
## 4. 目标与非目标
|
||||
|
||||
### 4.1 目标
|
||||
|
||||
- 在扩展 `popup` 中提供登录和登出入口
|
||||
- 在 `background service worker` 中统一处理 Logto 认证
|
||||
- 在内容脚本中基于登录态控制插件可用性
|
||||
- 登录成功后可读取基础用户信息
|
||||
- 为未来业务后端 API 预留 `getAccessToken(resource, scopes)` 能力
|
||||
- 通过 TDD 落地实现,保证认证状态切换和权限门控可回归测试
|
||||
|
||||
### 4.2 非目标
|
||||
|
||||
- 当前版本不接真实业务后端 API
|
||||
- 当前版本不支持开放注册
|
||||
- 当前版本不做复杂账号中心页面
|
||||
- 当前版本不要求多标签页实时广播登录态变化
|
||||
- 当前版本不默认展示完整 token 明文
|
||||
|
||||
## 5. 推荐架构
|
||||
|
||||
采用方案:`Background 认证中枢 + Popup 展示 + Content 只消费状态`
|
||||
|
||||
### 5.1 模块边界
|
||||
|
||||
#### `src/background/auth/*`
|
||||
|
||||
职责:
|
||||
|
||||
- 初始化 Logto 客户端
|
||||
- 执行 `signIn`
|
||||
- 执行 `signOut`
|
||||
- 执行 `getAuthState`
|
||||
- 执行 `getAccessToken`
|
||||
- 统一收敛认证错误
|
||||
|
||||
约束:
|
||||
|
||||
- 它是唯一直接接触 Logto SDK 和登录流程的层
|
||||
- 内容脚本和 popup 都不直接复制认证流程
|
||||
|
||||
#### `src/popup/*`
|
||||
|
||||
职责:
|
||||
|
||||
- 作为唯一登录入口
|
||||
- 展示未登录态、已登录态、错误态
|
||||
- 在开发模式下显示调试信息
|
||||
- 向 background 发送认证消息
|
||||
|
||||
#### `src/content/*`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面启动时查询认证状态
|
||||
- 未登录时渲染禁用态工具栏
|
||||
- 已登录后再初始化现有筛选、排序、导出能力
|
||||
|
||||
约束:
|
||||
|
||||
- 不直接依赖 Logto SDK
|
||||
- 不自己发起登录
|
||||
- 不长期缓存 access token
|
||||
|
||||
#### `src/shared/auth-messages.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 定义 popup / content / background 之间的消息协议
|
||||
- 避免认证消息散落在多个文件中
|
||||
|
||||
### 5.2 为什么这样分层
|
||||
|
||||
- 符合 Logto 官方 Chrome 扩展接法
|
||||
- 符合 MV3 生命周期特征,避免把认证流程绑死在不稳定的内容脚本生命周期上
|
||||
- 后续接业务后端时只需要扩展 background 的 token 获取逻辑
|
||||
- 权限边界清晰,未登录时不会出现“部分功能还能偷偷使用”的行为
|
||||
|
||||
## 6. 推荐目录结构
|
||||
|
||||
建议在当前项目中新增以下结构:
|
||||
|
||||
```text
|
||||
src/
|
||||
background/
|
||||
auth/
|
||||
client.ts
|
||||
controller.ts
|
||||
state.ts
|
||||
types.ts
|
||||
index.ts
|
||||
popup/
|
||||
index.html
|
||||
index.ts
|
||||
view.ts
|
||||
types.ts
|
||||
shared/
|
||||
auth-messages.ts
|
||||
auth-config.ts
|
||||
content/
|
||||
market/
|
||||
auth-gate.ts
|
||||
...
|
||||
```
|
||||
|
||||
目录约束:
|
||||
|
||||
- `background/auth/*` 只做认证中枢,不掺杂市场业务逻辑
|
||||
- `popup/*` 只处理展示和用户交互
|
||||
- `shared/*` 放跨入口共享的数据结构和配置解析
|
||||
- `content/market/auth-gate.ts` 只做登录态门控,不直接写登录流程
|
||||
|
||||
## 7. 配置设计
|
||||
|
||||
当前版本只定义占位配置,不写死真实值。
|
||||
|
||||
建议新增 `src/shared/auth-config.ts`,集中管理以下配置:
|
||||
|
||||
```ts
|
||||
export interface AuthConfig {
|
||||
logtoEndpoint: string;
|
||||
appId: string;
|
||||
apiResource: string;
|
||||
scopes: string[];
|
||||
enableDevAuthPanel: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
建议占位字段:
|
||||
|
||||
- `logtoEndpoint`: `https://<your-tenant>.logto.app`
|
||||
- `appId`: `<chrome-extension-app-id>`
|
||||
- `apiResource`: `<your-global-api-resource-indicator>`
|
||||
- `scopes`: `["openid", "profile", "offline_access"]`
|
||||
- `enableDevAuthPanel`: `false`
|
||||
|
||||
配置要求:
|
||||
|
||||
- 缺失配置时必须明确报错
|
||||
- 不允许 silent fail
|
||||
- 后续真实接入时应能通过单点改动切换环境
|
||||
|
||||
## 8. Manifest 变更要求
|
||||
|
||||
现有 manifest 仅覆盖下载和内容脚本注入,接入 Logto 后至少需要补充:
|
||||
|
||||
- `permissions` 增加 `identity`
|
||||
- `permissions` 保留或补充 `storage`
|
||||
- 新增 `action.default_popup`
|
||||
- 根据实际请求域名增加必要的 `host_permissions`
|
||||
|
||||
示意要求:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["downloads", "identity", "storage"],
|
||||
"action": {
|
||||
"default_popup": "popup/index.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实际值需要结合构建输出路径调整,但规范上必须支持 popup 与 identity 登录流程。
|
||||
|
||||
## 9. 认证与会话数据流
|
||||
|
||||
### 9.1 登录流程
|
||||
|
||||
1. 用户点击扩展图标,打开 `popup`
|
||||
2. `popup` 读取 background 返回的 `auth state`
|
||||
3. 如果未登录,点击“登录 Logto”
|
||||
4. `popup` 发送 `auth:sign-in` 给 background
|
||||
5. background 发起 Logto 登录流程
|
||||
6. 登录成功后,background 更新本地认证摘要状态
|
||||
7. `popup` 重新读取状态并渲染已登录信息
|
||||
|
||||
### 9.2 内容脚本门控流程
|
||||
|
||||
1. 内容脚本进入星图市场页
|
||||
2. 插件初始化前先发送 `auth:get-state`
|
||||
3. 如果未登录,渲染禁用态,不执行当前业务能力初始化
|
||||
4. 如果已登录,继续执行现有注入逻辑
|
||||
|
||||
### 9.3 登出流程
|
||||
|
||||
1. 用户在 `popup` 点击退出登录
|
||||
2. `popup` 发送 `auth:sign-out`
|
||||
3. background 清理会话
|
||||
4. `popup` 回到未登录态
|
||||
5. 已打开的市场页在刷新后进入禁用态
|
||||
|
||||
### 9.4 token 策略
|
||||
|
||||
当前版本采用:
|
||||
|
||||
- Authorization Code Flow + PKCE
|
||||
- 基础 scopes:`openid profile offline_access`
|
||||
- `resource` 对应未来业务后端 API 的 global API resource
|
||||
|
||||
约束:
|
||||
|
||||
- `ID token` 仅用于身份展示
|
||||
- `Access token` 仅用于未来业务后端 API 调用
|
||||
- 内容脚本只按需向 background 请求 token,不本地长期存储
|
||||
|
||||
## 10. 消息协议建议
|
||||
|
||||
建议新增一组显式消息类型:
|
||||
|
||||
```ts
|
||||
type AuthMessage =
|
||||
| { type: "auth:get-state" }
|
||||
| { type: "auth:sign-in" }
|
||||
| { type: "auth:sign-out" }
|
||||
| { type: "auth:get-access-token" };
|
||||
```
|
||||
|
||||
建议的状态结构:
|
||||
|
||||
```ts
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
userInfo: {
|
||||
sub: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
scopes: string[];
|
||||
resource: string | null;
|
||||
accessTokenExpiresAt: number | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
设计要求:
|
||||
|
||||
- 状态必须可序列化
|
||||
- 消息响应必须有稳定结构
|
||||
- 失败时返回明确错误码或错误文案
|
||||
|
||||
## 11. Popup 交互设计
|
||||
|
||||
### 11.1 未登录态
|
||||
|
||||
显示内容:
|
||||
|
||||
- 插件名称
|
||||
- 说明文案:“登录后才能使用星图增强功能”
|
||||
- 主按钮:“登录 Logto”
|
||||
- 最近一次登录失败摘要(如果存在)
|
||||
|
||||
### 11.2 已登录态
|
||||
|
||||
默认展示:
|
||||
|
||||
- 已登录状态
|
||||
- 用户名
|
||||
- 邮箱
|
||||
- `退出登录` 按钮
|
||||
|
||||
### 11.3 开发调试态
|
||||
|
||||
仅在 `enableDevAuthPanel` 开启时显示:
|
||||
|
||||
- `isAuthenticated`
|
||||
- `sub`
|
||||
- `scopes`
|
||||
- `resource`
|
||||
- token 是否可获取
|
||||
- access token 预计过期时间
|
||||
- 最近一次认证错误
|
||||
- 手动“刷新状态”按钮
|
||||
|
||||
默认不显示完整 token 字符串。
|
||||
|
||||
## 12. 内容脚本权限门控设计
|
||||
|
||||
### 12.1 未登录态
|
||||
|
||||
插件工具栏应替换为禁用面板,显示:
|
||||
|
||||
- 主提示:“请先登录插件”
|
||||
- 次提示:“打开扩展弹窗完成登录后刷新本页”
|
||||
- 引导按钮:“去登录”
|
||||
|
||||
“去登录”按钮行为:
|
||||
|
||||
- 不直接尝试从内容脚本打开 popup
|
||||
- 仅提示用户点击浏览器工具栏中的扩展图标完成登录
|
||||
|
||||
未登录时:
|
||||
|
||||
- 不初始化筛选
|
||||
- 不初始化排序
|
||||
- 不初始化导出
|
||||
- 不发起后续业务请求
|
||||
|
||||
### 12.2 已登录态
|
||||
|
||||
保持当前市场增强行为不变。
|
||||
|
||||
### 12.3 登录失效态
|
||||
|
||||
如果内容脚本运行过程中发现 background 返回未登录或 token 不可用:
|
||||
|
||||
- 立即切回禁用态
|
||||
- 提示“登录已失效,请重新登录”
|
||||
|
||||
## 13. 错误处理设计
|
||||
|
||||
至少覆盖以下错误:
|
||||
|
||||
- Logto 配置缺失
|
||||
- 登录取消
|
||||
- 登录回调失败
|
||||
- token 获取失败
|
||||
- popup 无法读取 background 状态
|
||||
- 内容脚本无法获取登录态
|
||||
|
||||
设计要求:
|
||||
|
||||
- popup 中展示用户可理解的错误摘要
|
||||
- background 中保留标准化错误结构
|
||||
- 内容脚本遇到认证错误时优先进入禁用态,而不是继续执行业务逻辑
|
||||
|
||||
## 14. TDD 实施要求
|
||||
|
||||
实现必须采用 TDD,遵循:
|
||||
|
||||
1. 先写失败测试
|
||||
2. 明确验证测试因缺失功能而失败
|
||||
3. 再写最小实现让测试通过
|
||||
4. 最后做必要重构
|
||||
|
||||
不允许先写生产代码再补测试。
|
||||
|
||||
## 15. 测试分层建议
|
||||
|
||||
### 15.1 单元测试:消息协议与状态结构
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/auth-messages.test.ts`
|
||||
- `tests/auth-state-store.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- 消息类型收发正确
|
||||
- 状态序列化结构稳定
|
||||
- 配置缺失时能返回明确错误
|
||||
|
||||
### 15.2 单元测试:background 认证协调器
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/background-auth-controller.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- `signIn` 调用认证入口
|
||||
- `signOut` 清理会话
|
||||
- `getAuthState` 返回认证摘要
|
||||
- `getAccessToken` 在未登录或配置无效时失败明确
|
||||
|
||||
### 15.3 单元测试:popup UI
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/popup-entry.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- 未登录显示登录按钮
|
||||
- 已登录显示用户信息和退出按钮
|
||||
- 开发模式显示调试区
|
||||
- 错误态显示错误摘要
|
||||
|
||||
### 15.4 集成测试:内容脚本门控
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/market-auth-gating.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- 未登录时市场页工具栏进入禁用态
|
||||
- 已登录后才初始化现有筛选、排序、导出
|
||||
- 运行中登录失效时切回禁用态
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
- 扩展加载后存在可用的 `popup`
|
||||
- 未登录时 `popup` 显示登录入口
|
||||
- 登录成功后 `popup` 显示基础用户信息
|
||||
- 开发模式下 `popup` 能显示认证调试信息
|
||||
- 未登录时市场页插件整体禁用
|
||||
- 登录后刷新市场页,现有插件能力恢复
|
||||
- 登出后刷新市场页,插件重新禁用
|
||||
- 配置缺失时,popup / background / content 都能给出明确错误,而不是静默失败
|
||||
|
||||
## 17. 后续实现顺序建议
|
||||
|
||||
建议实施顺序:
|
||||
|
||||
1. 补 manifest 与构建入口,先让 popup 可以独立加载
|
||||
2. 写 `auth-messages` 与 `auth-config` 的失败测试
|
||||
3. 写 background auth controller 的失败测试并落最小实现
|
||||
4. 写 popup 状态渲染测试并接上 background
|
||||
5. 写内容脚本门控测试,再把市场页逻辑接入认证态
|
||||
6. 最后补开发调试面板
|
||||
|
||||
## 18. 当前仍保留为占位的实施参数
|
||||
|
||||
这些值在真正开始编码前仍需由你提供:
|
||||
|
||||
- Logto tenant endpoint
|
||||
- Logto Chrome extension appId
|
||||
- Global API resource 标识
|
||||
- 是否存在额外自定义 scopes
|
||||
- 调试面板是否区分开发包与生产包
|
||||
|
||||
在这些真实值到位前,可以先完成目录搭建、消息协议、状态门控和测试骨架。
|
||||
Generated
+138
-2
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "market-plugin-impl",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0421.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "market-plugin-impl",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0421.2",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@logto/chrome-extension": "^0.1.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^29.0.2",
|
||||
"tsup": "^8.5.1",
|
||||
@@ -752,6 +755,48 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@logto/browser": {
|
||||
"version": "3.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@logto/browser/-/browser-3.0.13.tgz",
|
||||
"integrity": "sha512-SlZ76XiVh2es6eFB1M+ldV6b60eC3+eeKoRQ8/AvOlpwHhrY/v2FPw5LOd/vZ+WYjzDqsxNtOMdhTdliHZ7V1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@logto/client": "^3.1.8",
|
||||
"@silverhand/essentials": "^2.9.3",
|
||||
"js-base64": "^3.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@logto/chrome-extension": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@logto/chrome-extension/-/chrome-extension-0.1.27.tgz",
|
||||
"integrity": "sha512-XIMS3ysrkjgDxtQs+9jcSc2jF74oKbHIqzoaSJ/nN2yqLqUadHInUelpR5D00HRvpNGrEDqzgtD6+kNuMOgSUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@logto/browser": "^3.0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@logto/client": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.8.tgz",
|
||||
"integrity": "sha512-f6NcPOV/K1IpPm4ccARWeYpQVMeN4mfikGg+5Qw1rcIPYPUpD5BmDsQbVTAnDepCMbC7syzRerZmbwL8S3UL+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@logto/js": "^6.1.2",
|
||||
"@silverhand/essentials": "^2.9.3",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"jose": "^5.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@logto/js": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.2.tgz",
|
||||
"integrity": "sha512-YB/TfixPGI0Spbs8LXiKuASOKFUE9VmlTkXiPfgg3UXQsIPTU71KjKxEXZRePu3xdPNhsZ6WtnRfRvvcpP+KGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@silverhand/essentials": "^2.9.3",
|
||||
"camelcase-keys": "^9.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -1395,6 +1440,16 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@silverhand/essentials": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz",
|
||||
"integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12.0",
|
||||
"pnpm": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -1617,6 +1672,36 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
|
||||
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-keys": {
|
||||
"version": "9.1.3",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
|
||||
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase": "^8.0.0",
|
||||
"map-obj": "5.0.0",
|
||||
"quick-lru": "^6.1.1",
|
||||
"type-fest": "^4.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1887,6 +1972,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
@@ -1897,6 +1991,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
|
||||
@@ -2249,6 +2349,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/map-obj": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
|
||||
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
@@ -2472,6 +2584,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -2857,6 +2981,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
|
||||
+5
-1
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "market-plugin-impl",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0421.2",
|
||||
"description": "Bootstrap for the Xingtu market Chrome MV3 extension.",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"mock:protected-api": "node scripts/mock-protected-api.mjs",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/chrome-extension": "^0.1.27"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"jsdom": "^29.0.2",
|
||||
|
||||
@@ -10,6 +10,8 @@ const distDir = path.join(projectRoot, "dist");
|
||||
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
await mkdir(path.join(distDir, "content"), { recursive: true });
|
||||
await mkdir(path.join(distDir, "background"), { recursive: true });
|
||||
await mkdir(path.join(distDir, "popup"), { recursive: true });
|
||||
|
||||
await build({
|
||||
entry: {
|
||||
@@ -32,7 +34,45 @@ await build({
|
||||
}
|
||||
});
|
||||
|
||||
await build({
|
||||
entry: {
|
||||
index: path.join(projectRoot, "src/background/index.ts")
|
||||
},
|
||||
format: ["iife"],
|
||||
platform: "browser",
|
||||
target: "chrome114",
|
||||
outDir: path.join(distDir, "background"),
|
||||
clean: false,
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
outExtension() {
|
||||
return { js: ".js" };
|
||||
}
|
||||
});
|
||||
|
||||
await build({
|
||||
entry: {
|
||||
index: path.join(projectRoot, "src/popup/index.ts")
|
||||
},
|
||||
format: ["iife"],
|
||||
platform: "browser",
|
||||
target: "chrome114",
|
||||
outDir: path.join(distDir, "popup"),
|
||||
clean: false,
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
outExtension() {
|
||||
return { js: ".js" };
|
||||
}
|
||||
});
|
||||
|
||||
await cp(
|
||||
path.join(projectRoot, "src/manifest.json"),
|
||||
path.join(distDir, "manifest.json")
|
||||
);
|
||||
await cp(
|
||||
path.join(projectRoot, "src/popup/index.html"),
|
||||
path.join(distDir, "popup/index.html")
|
||||
);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import http from "node:http";
|
||||
|
||||
export function createMockProtectedApiServer({ port = 4319 } = {}) {
|
||||
let server;
|
||||
|
||||
return {
|
||||
get baseUrl() {
|
||||
const address = server?.address();
|
||||
const resolvedPort =
|
||||
typeof address === "object" && address ? address.port : port;
|
||||
|
||||
return `http://127.0.0.1:${resolvedPort}`;
|
||||
},
|
||||
async start() {
|
||||
server = http.createServer(async (request, response) => {
|
||||
if (request.url === "/api/mock/protected") {
|
||||
const authHeader = readBearerToken(request, response);
|
||||
if (!authHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
source: "mock-protected-api",
|
||||
message: "authorized",
|
||||
receivedAuthHeader: authHeader
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.url === "/api/mock/batches" && request.method === "POST") {
|
||||
const authHeader = readBearerToken(request, response);
|
||||
if (!authHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJsonBody(request);
|
||||
const authors = Array.isArray(payload?.authors) ? payload.authors : [];
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
source: "mock-batch-submit",
|
||||
acceptedCount: authors.length,
|
||||
batchId:
|
||||
typeof payload?.batchId === "string" ? payload.batchId : null,
|
||||
receivedAuthHeader: authHeader
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "not-found" }));
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
server.listen(port, "127.0.0.1", resolve);
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readBearerToken(request, response) {
|
||||
const authHeader = request.headers.authorization ?? "";
|
||||
const isBearer =
|
||||
typeof authHeader === "string" &&
|
||||
authHeader.startsWith("Bearer ") &&
|
||||
authHeader.length > "Bearer ".length;
|
||||
|
||||
if (!isBearer) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||
return null;
|
||||
}
|
||||
|
||||
return authHeader;
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const chunks = [];
|
||||
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const server = createMockProtectedApiServer();
|
||||
await server.start();
|
||||
console.log(`mock protected api listening on ${server.baseUrl}`);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import LogtoClient from "@logto/chrome-extension";
|
||||
|
||||
import { readAuthConfig } from "../../shared/auth-config";
|
||||
import type { AuthClientLike } from "./types";
|
||||
|
||||
export function createLogtoAuthClient(): AuthClientLike {
|
||||
const config = readAuthConfig();
|
||||
const client = new LogtoClient({
|
||||
appId: config.appId,
|
||||
endpoint: config.logtoEndpoint,
|
||||
resources: [config.apiResource],
|
||||
scopes: config.scopes
|
||||
});
|
||||
|
||||
return {
|
||||
getAccessToken(resource?: string) {
|
||||
return client.getAccessToken(resource);
|
||||
},
|
||||
getIdTokenClaims() {
|
||||
return client.getIdTokenClaims();
|
||||
},
|
||||
isAuthenticated() {
|
||||
return client.isAuthenticated();
|
||||
},
|
||||
signIn() {
|
||||
return client.signIn(readChromeIdentity().getRedirectURL("/callback"));
|
||||
},
|
||||
signOut() {
|
||||
return client.signOut(readChromeIdentity().getRedirectURL());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readChromeIdentity(): {
|
||||
getRedirectURL: (path?: string) => string;
|
||||
} {
|
||||
const identity = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
identity?: {
|
||||
getRedirectURL?: (path?: string) => string;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome?.identity;
|
||||
|
||||
if (typeof identity?.getRedirectURL !== "function") {
|
||||
throw new Error("chrome.identity.getRedirectURL is unavailable");
|
||||
}
|
||||
|
||||
return {
|
||||
getRedirectURL: identity.getRedirectURL.bind(identity)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readAuthConfig, type AuthConfig } from "../../shared/auth-config";
|
||||
import { createLoggedInAuthState, createLoggedOutAuthState } from "./state";
|
||||
import type { AuthClientLike } from "./types";
|
||||
|
||||
export interface AuthController {
|
||||
getAccessToken(): Promise<string>;
|
||||
getAuthState(): Promise<ReturnType<typeof createLoggedOutAuthState>>;
|
||||
signIn(): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAuthController(options: {
|
||||
authClient: AuthClientLike;
|
||||
config?: AuthConfig;
|
||||
}): AuthController {
|
||||
const config = options.config ?? readAuthConfig();
|
||||
|
||||
return {
|
||||
async getAccessToken() {
|
||||
return options.authClient.getAccessToken(config.apiResource);
|
||||
},
|
||||
async getAuthState() {
|
||||
const isAuthenticated = await options.authClient.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return createLoggedOutAuthState(config);
|
||||
}
|
||||
|
||||
const claims = await options.authClient.getIdTokenClaims();
|
||||
return createLoggedInAuthState(claims, config);
|
||||
},
|
||||
async signIn() {
|
||||
await options.authClient.signIn();
|
||||
},
|
||||
async signOut() {
|
||||
await options.authClient.signOut();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AuthConfig } from "../../shared/auth-config";
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
|
||||
export function createLoggedOutAuthState(
|
||||
config?: Pick<AuthConfig, "apiResource">
|
||||
): AuthStateValue {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
resource: config?.apiResource ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function createLoggedInAuthState(
|
||||
claims: Record<string, unknown> | null | undefined,
|
||||
config?: Pick<AuthConfig, "apiResource" | "scopes">
|
||||
): AuthStateValue {
|
||||
return {
|
||||
accessTokenExpiresAt: null,
|
||||
isAuthenticated: true,
|
||||
resource: config?.apiResource ?? null,
|
||||
scopes: config?.scopes ?? [],
|
||||
tokenAvailable: true,
|
||||
userInfo: {
|
||||
email: readStringClaim(claims, "email"),
|
||||
name: readStringClaim(claims, "name"),
|
||||
sub: readStringClaim(claims, "sub"),
|
||||
username: readStringClaim(claims, "username")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readStringClaim(
|
||||
claims: Record<string, unknown> | null | undefined,
|
||||
key: string
|
||||
): string | undefined {
|
||||
const value = claims?.[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface AuthClientLike {
|
||||
getAccessToken(resource?: string): Promise<string>;
|
||||
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
|
||||
isAuthenticated(): Promise<boolean>;
|
||||
signIn(): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { createAuthController, type AuthController } from "./auth/controller";
|
||||
import { createLogtoAuthClient } from "./auth/client";
|
||||
import {
|
||||
isAuthRequestMessage,
|
||||
type AuthResponseMessage
|
||||
} from "../shared/auth-messages";
|
||||
import { createBatchSubmitClient } from "../shared/batch-submit-client";
|
||||
import { createBackendMetricsClient } from "../shared/backend-metrics-client";
|
||||
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
|
||||
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
|
||||
|
||||
interface ChromeDownloadsLike {
|
||||
download(
|
||||
options: {
|
||||
filename: string;
|
||||
saveAs?: boolean;
|
||||
url: string;
|
||||
},
|
||||
callback?: () => void
|
||||
): Promise<unknown> | void;
|
||||
}
|
||||
|
||||
interface ChromeRuntimeLike {
|
||||
onMessage?: {
|
||||
addListener(
|
||||
listener: (
|
||||
message: unknown,
|
||||
sender: unknown,
|
||||
sendResponse: (response: unknown) => void
|
||||
) => boolean | void
|
||||
): void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChromeLike {
|
||||
downloads?: ChromeDownloadsLike;
|
||||
runtime?: ChromeRuntimeLike;
|
||||
}
|
||||
|
||||
type DownloadMarketCsvMessage = {
|
||||
csv: string;
|
||||
filename: string;
|
||||
type: "download-market-csv";
|
||||
};
|
||||
|
||||
type BatchSubmitMessage = {
|
||||
payload: unknown;
|
||||
type: "batch:submit";
|
||||
};
|
||||
|
||||
export function registerBackgroundMessageHandler(
|
||||
chromeLike: ChromeLike = readChromeLike(),
|
||||
dependencies: {
|
||||
authController?: AuthController;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<unknown>;
|
||||
submitBatch?: (payload: unknown) => Promise<unknown>;
|
||||
} = {}
|
||||
): void {
|
||||
let authController = dependencies.authController;
|
||||
let searchBackendMetrics = dependencies.searchBackendMetrics;
|
||||
let submitBatch = dependencies.submitBatch;
|
||||
|
||||
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
|
||||
if (isDownloadMarketCsvMessage(message)) {
|
||||
void triggerCsvDownload(chromeLike, message)
|
||||
.then(() => {
|
||||
sendResponse({ ok: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isBatchSubmitMessage(message)) {
|
||||
authController ??= createAuthController({
|
||||
authClient: createLogtoAuthClient()
|
||||
});
|
||||
submitBatch ??= createBatchSubmitClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
getAccessToken: () => authController!.getAccessToken(),
|
||||
sendMessage: () =>
|
||||
Promise.reject(new Error("background batch submit does not use sendMessage"))
|
||||
}).submitBatch;
|
||||
|
||||
void submitBatch(message.payload)
|
||||
.then((value) => {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
type: "batch:ack",
|
||||
value
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
type: "batch:error"
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isBackendMetricsSearchRequestMessage(message)) {
|
||||
authController ??= createAuthController({
|
||||
authClient: createLogtoAuthClient()
|
||||
});
|
||||
searchBackendMetrics ??= createBackendMetricsClient({
|
||||
baseUrl: DEFAULT_BACKEND_METRICS_BASE_URL,
|
||||
getAccessToken: () => authController!.getAccessToken()
|
||||
}).searchByStarIds;
|
||||
|
||||
void searchBackendMetrics(message.value.starIds)
|
||||
.then((rows) => {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
type: "backend-metrics:result",
|
||||
value: {
|
||||
rows
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
type: "backend-metrics:error"
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isAuthRequestMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
authController ??= createAuthController({
|
||||
authClient: createLogtoAuthClient()
|
||||
});
|
||||
|
||||
void handleAuthMessage(authController, message)
|
||||
.then((response) => {
|
||||
sendResponse(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
type: "auth:error"
|
||||
} satisfies AuthResponseMessage);
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuthMessage(
|
||||
authController: AuthController,
|
||||
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
|
||||
): Promise<AuthResponseMessage> {
|
||||
if (message.type === "auth:get-state") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: await authController.getAuthState()
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "auth:get-access-token") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: {
|
||||
accessToken: await authController.getAccessToken()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "auth:sign-in") {
|
||||
await authController.signIn();
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:ack"
|
||||
};
|
||||
}
|
||||
|
||||
await authController.signOut();
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:ack"
|
||||
};
|
||||
}
|
||||
|
||||
function readChromeLike(): ChromeLike {
|
||||
return (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: ChromeLike;
|
||||
}
|
||||
).chrome ?? {};
|
||||
}
|
||||
|
||||
async function triggerCsvDownload(
|
||||
chromeLike: ChromeLike,
|
||||
message: DownloadMarketCsvMessage
|
||||
): Promise<void> {
|
||||
if (!chromeLike.downloads?.download) {
|
||||
throw new Error("chrome.downloads.download is unavailable");
|
||||
}
|
||||
|
||||
const csvUrl = `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${message.csv}`)}`;
|
||||
await Promise.resolve(
|
||||
chromeLike.downloads.download({
|
||||
filename: message.filename,
|
||||
saveAs: false,
|
||||
url: csvUrl
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isDownloadMarketCsvMessage(
|
||||
message: unknown
|
||||
): message is DownloadMarketCsvMessage {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = message as Partial<DownloadMarketCsvMessage>;
|
||||
return (
|
||||
candidate.type === "download-market-csv" &&
|
||||
typeof candidate.csv === "string" &&
|
||||
typeof candidate.filename === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = message as Partial<BatchSubmitMessage>;
|
||||
return candidate.type === "batch:submit" && "payload" in candidate;
|
||||
}
|
||||
|
||||
registerBackgroundMessageHandler();
|
||||
@@ -2,17 +2,26 @@ import {
|
||||
createMarketController,
|
||||
type CreateMarketControllerOptions
|
||||
} from "./market/index";
|
||||
import { renderMarketAuthGate } from "./market/auth-gate";
|
||||
import {
|
||||
isAuthResponseMessage,
|
||||
type AuthStateValue
|
||||
} from "../shared/auth-messages";
|
||||
|
||||
interface ChromeRuntimeLike {
|
||||
getURL?: (path: string) => string;
|
||||
id?: string;
|
||||
sendMessage?: (message: unknown) => void | Promise<unknown>;
|
||||
}
|
||||
|
||||
const DOWNLOAD_MARKET_CSV_MESSAGE = "download-market-csv";
|
||||
|
||||
interface BootContentScriptOptions {
|
||||
createMarketController?: (
|
||||
options: CreateMarketControllerOptions
|
||||
) => { dispose?: () => void; ready: Promise<void> };
|
||||
document?: Document;
|
||||
sendAuthMessage?: (message: unknown) => Promise<unknown>;
|
||||
window?: Window;
|
||||
}
|
||||
|
||||
@@ -23,19 +32,47 @@ export async function bootContentScript(
|
||||
const currentDocument = options.document ?? document;
|
||||
const controllerFactory =
|
||||
options.createMarketController ?? createMarketController;
|
||||
const sendAuthMessage =
|
||||
options.sendAuthMessage ?? createRuntimeMessageSender();
|
||||
|
||||
if (!isMarketPage(currentWindow.location.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authState = await readAuthState(sendAuthMessage);
|
||||
if (!authState?.isAuthenticated) {
|
||||
renderMarketAuthGate(currentDocument, currentWindow);
|
||||
return {
|
||||
ready: Promise.resolve()
|
||||
};
|
||||
}
|
||||
|
||||
installMarketPageBridge(currentDocument);
|
||||
|
||||
return controllerFactory({
|
||||
document: currentDocument,
|
||||
onCsvReady: (csv: string) => {
|
||||
if (requestCsvDownload(csv)) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadCsv(currentDocument, currentWindow, csv);
|
||||
},
|
||||
window: currentWindow
|
||||
});
|
||||
}
|
||||
|
||||
async function readAuthState(
|
||||
sendMessage: (message: unknown) => Promise<unknown>
|
||||
): Promise<AuthStateValue | null> {
|
||||
const response = await sendMessage({ type: "auth:get-state" });
|
||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.value;
|
||||
}
|
||||
|
||||
function isMarketPage(url: string): boolean {
|
||||
const parsedUrl = new URL(url);
|
||||
const isXingtuHost =
|
||||
@@ -72,6 +109,59 @@ function bootstrapContentScript() {
|
||||
|
||||
bootstrapContentScript();
|
||||
|
||||
function requestCsvDownload(csv: string): boolean {
|
||||
const runtime = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: { runtime?: ChromeRuntimeLike };
|
||||
}
|
||||
).chrome?.runtime;
|
||||
|
||||
if (!runtime?.id || typeof runtime.sendMessage !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtime.sendMessage({
|
||||
csv,
|
||||
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
|
||||
return async (message: unknown) => {
|
||||
const runtime = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: { runtime?: ChromeRuntimeLike };
|
||||
}
|
||||
).chrome?.runtime;
|
||||
|
||||
if (typeof runtime?.sendMessage !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runtime.sendMessage(message);
|
||||
};
|
||||
}
|
||||
|
||||
function downloadCsv(document: Document, window: Window, csv: string): void {
|
||||
const blob = new Blob(["\uFEFF", csv], {
|
||||
type: "text/csv;charset=utf-8"
|
||||
});
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
function formatTimestampForFilename(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function installMarketPageBridge(document: Document) {
|
||||
if (
|
||||
document.documentElement.querySelector(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export function renderMarketAuthGate(
|
||||
document: Document,
|
||||
currentWindow: Window
|
||||
): HTMLElement {
|
||||
const existingGate = document.querySelector(
|
||||
'[data-market-auth-gate="root"]'
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (existingGate) {
|
||||
return existingGate;
|
||||
}
|
||||
|
||||
const root = document.createElement("section");
|
||||
root.dataset.marketAuthGate = "root";
|
||||
root.innerHTML = `
|
||||
<strong>请先登录插件</strong>
|
||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||
<button type="button" data-market-auth-help="button">去登录</button>
|
||||
`;
|
||||
|
||||
root
|
||||
.querySelector('[data-market-auth-help="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
currentWindow.alert("请点击浏览器工具栏中的扩展图标完成登录");
|
||||
});
|
||||
|
||||
document.body.prepend(root);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export interface BatchPayload {
|
||||
authors: Array<{
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
}>;
|
||||
batchId: string;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
creatorName: string;
|
||||
logtoUserId: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
export function createBatchPayload(options: {
|
||||
authState: AuthStateValue;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
records: MarketRecord[];
|
||||
}): BatchPayload {
|
||||
const logtoUserId = options.authState.userInfo?.sub?.trim();
|
||||
if (!logtoUserId) {
|
||||
throw new Error("batch submit user id unavailable");
|
||||
}
|
||||
|
||||
const resource = options.authState.resource?.trim();
|
||||
if (!resource) {
|
||||
throw new Error("batch submit resource unavailable");
|
||||
}
|
||||
|
||||
const batchName = options.batchName.trim();
|
||||
if (!batchName) {
|
||||
throw new Error("batch submit batch name is required");
|
||||
}
|
||||
|
||||
return {
|
||||
authors: options.records.map((record) => ({
|
||||
authorId: record.authorId,
|
||||
authorName: record.authorName
|
||||
})),
|
||||
batchId: `${batchName}-${options.createdAt}`,
|
||||
batchName,
|
||||
createdAt: options.createdAt,
|
||||
creatorName:
|
||||
options.authState.userInfo?.name ??
|
||||
options.authState.userInfo?.username ??
|
||||
logtoUserId,
|
||||
logtoUserId,
|
||||
resource
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
type CsvColumn = {
|
||||
header: string;
|
||||
readValue: (record: MarketRecord) => string;
|
||||
};
|
||||
|
||||
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "达人ID",
|
||||
readValue: (record: MarketRecord) => record.authorId
|
||||
@@ -18,7 +23,10 @@ const CSV_COLUMNS = [
|
||||
{
|
||||
header: "21-60s报价",
|
||||
readValue: (record: MarketRecord) => record.price21To60s ?? ""
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const RATE_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "单视频看后搜率",
|
||||
readValue: (record: MarketRecord) =>
|
||||
@@ -32,18 +40,41 @@ const CSV_COLUMNS = [
|
||||
record.rates?.personalVideoAfterSearchRate
|
||||
? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate)
|
||||
: ""
|
||||
},
|
||||
{
|
||||
header: "插件数据状态",
|
||||
readValue: (record: MarketRecord) => record.status
|
||||
}
|
||||
] as const;
|
||||
];
|
||||
|
||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
const headerLine = CSV_COLUMNS.map((column) => column.header).join(",");
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
const csvColumns = [...baseColumns, ...RATE_COLUMNS];
|
||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||
const rowLines = records.map((record) =>
|
||||
CSV_COLUMNS.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||
);
|
||||
|
||||
return [headerLine, ...rowLines].join("\n");
|
||||
}
|
||||
|
||||
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const orderedHeaders: string[] = [];
|
||||
const seenHeaders = new Set<string>();
|
||||
|
||||
records.forEach((record) => {
|
||||
Object.keys(record.exportFields ?? {}).forEach((header) => {
|
||||
if (seenHeaders.has(header)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenHeaders.add(header);
|
||||
orderedHeaders.push(header);
|
||||
});
|
||||
});
|
||||
|
||||
if (orderedHeaders.length === 0) {
|
||||
return FALLBACK_BASE_COLUMNS;
|
||||
}
|
||||
|
||||
return orderedHeaders.map((header) => ({
|
||||
header,
|
||||
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import {
|
||||
normalizeFractionRateDisplay,
|
||||
normalizeRateDisplay
|
||||
} from "../../shared/rate-normalizer";
|
||||
import type { AfterSearchRates } from "./types";
|
||||
import type { AfterSearchRates, BackendMetrics } from "./types";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
const BACKEND_COLUMN_KEY = "backendMetrics";
|
||||
const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate";
|
||||
const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate";
|
||||
const ACTION_HEADER_TEXT = "操作";
|
||||
const AUTHOR_HEADER_TEXT = "达人信息";
|
||||
const BACKEND_HEADER_TEXT = "秒探指标";
|
||||
const UNAVAILABLE_RATE_TEXT = "暂无来源";
|
||||
const UNAVAILABLE_BACKEND_METRICS_TEXT = "暂无数据";
|
||||
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
|
||||
|
||||
type RowOrderTarget = {
|
||||
@@ -20,6 +23,8 @@ type RowOrderTarget = {
|
||||
export interface MarketRowDom {
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
backendMetricsCell: HTMLElement;
|
||||
exportFields?: Record<string, string>;
|
||||
hasDirectRatesSource?: boolean;
|
||||
personalCell: HTMLElement;
|
||||
price21To60s?: string;
|
||||
@@ -38,10 +43,74 @@ export function syncMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root);
|
||||
}
|
||||
|
||||
export function readMarketPageSignature(root: ParentNode): string {
|
||||
const document = getOwnerDocument(root);
|
||||
const explicitPageIndex =
|
||||
document?.documentElement.getAttribute("data-test-page-index") ?? "";
|
||||
const activePageIndex =
|
||||
document
|
||||
?.querySelector(".el-pagination .number.active, .xt-pagination .number.active")
|
||||
?.textContent?.trim() ?? "";
|
||||
const table = syncMarketTable(root);
|
||||
const authorIds =
|
||||
table?.rows
|
||||
.map((row) => row.authorId)
|
||||
.filter((authorId) => Boolean(authorId))
|
||||
.join("|") ?? "";
|
||||
|
||||
return `${explicitPageIndex || activePageIndex}::${authorIds}`;
|
||||
}
|
||||
|
||||
export function findNextPageControl(root: ParentNode): HTMLElement | null {
|
||||
const document = getOwnerDocument(root);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitControl = document.querySelector('[data-testid="next-page"]');
|
||||
if (explicitControl instanceof document.defaultView!.HTMLElement) {
|
||||
return explicitControl;
|
||||
}
|
||||
|
||||
const paginationNextControl = document.querySelector(
|
||||
".el-pagination .btn-next, .xt-pagination .btn-next"
|
||||
);
|
||||
if (paginationNextControl instanceof document.defaultView!.HTMLElement) {
|
||||
return paginationNextControl;
|
||||
}
|
||||
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll("button, a, [role='button']")
|
||||
).filter(
|
||||
(element): element is HTMLElement =>
|
||||
element instanceof document.defaultView!.HTMLElement
|
||||
);
|
||||
|
||||
return (
|
||||
candidates.find((element) =>
|
||||
/下一页|next/i.test(normalizeExportCellText(element.textContent))
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function isPageControlDisabled(control: HTMLElement | null): boolean {
|
||||
if (!control) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (control instanceof HTMLButtonElement) {
|
||||
return control.disabled;
|
||||
}
|
||||
|
||||
return control.getAttribute("aria-disabled") === "true";
|
||||
}
|
||||
|
||||
export function renderMarketRowState(
|
||||
rowDom: MarketRowDom,
|
||||
record: MarketRecord
|
||||
): void {
|
||||
renderBackendMetricsCell(rowDom.backendMetricsCell, record);
|
||||
|
||||
if (record.status === "success" && record.rates) {
|
||||
rowDom.singleCell.textContent = readRateCellText(
|
||||
record.rates.singleVideoAfterSearchRate
|
||||
@@ -108,18 +177,23 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
|
||||
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
||||
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
||||
ensureSyntheticHeaderCell(header, BACKEND_COLUMN_KEY, BACKEND_HEADER_TEXT);
|
||||
|
||||
const headerLabelsByField = readSyntheticHeaderLabels(header);
|
||||
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
|
||||
(rowElement) => {
|
||||
const row = rowElement as HTMLElement;
|
||||
const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY);
|
||||
const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY);
|
||||
const backendMetricsCell = ensureSyntheticRowCell(row, BACKEND_COLUMN_KEY);
|
||||
|
||||
return {
|
||||
authorId: row.dataset.authorId ?? "",
|
||||
authorName:
|
||||
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
||||
"",
|
||||
backendMetricsCell,
|
||||
exportFields: readSyntheticExportFields(row, headerLabelsByField),
|
||||
hasDirectRatesSource: false,
|
||||
orderTargets: [
|
||||
{
|
||||
@@ -209,6 +283,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
const rowCount = getDirectContentCells(authorColumn).length;
|
||||
ensureDivHeaderCell(actionHeader, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
||||
ensureDivHeaderCell(actionHeader, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
||||
ensureDivHeaderCell(actionHeader, BACKEND_COLUMN_KEY, BACKEND_HEADER_TEXT);
|
||||
|
||||
const singleColumn = ensureDivBodyColumn(
|
||||
rightSection,
|
||||
@@ -222,15 +297,29 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
PERSONAL_COLUMN_KEY,
|
||||
rowCount
|
||||
);
|
||||
const backendMetricsColumn = ensureDivBodyColumn(
|
||||
rightSection,
|
||||
actionColumn,
|
||||
BACKEND_COLUMN_KEY,
|
||||
rowCount
|
||||
);
|
||||
syncContainerWidth(actionHeader.parentElement);
|
||||
syncContainerWidth(rightSection);
|
||||
|
||||
const allBodyColumns = Array.from(bodySection.children).flatMap((section) =>
|
||||
section instanceof root.ownerDocument.defaultView!.HTMLElement
|
||||
? getDirectContentColumns(section)
|
||||
: []
|
||||
);
|
||||
const allHeaderCells = Array.from(headerSection.children).flatMap((section) =>
|
||||
section instanceof root.ownerDocument.defaultView!.HTMLElement
|
||||
? getDirectHeaderCells(section)
|
||||
: []
|
||||
);
|
||||
const authorCells = getDirectContentCells(authorColumn);
|
||||
const singleCells = getDirectContentCells(singleColumn);
|
||||
const personalCells = getDirectContentCells(personalColumn);
|
||||
const backendMetricsCells = getDirectContentCells(backendMetricsColumn);
|
||||
const priceColumn = findPreviousColumn(actionColumn);
|
||||
const priceCells = priceColumn ? getDirectContentCells(priceColumn) : [];
|
||||
const vueMarketRows = readVueMarketRows(root);
|
||||
@@ -239,7 +328,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
const rows = authorCells.flatMap((authorCell, index) => {
|
||||
const singleCell = singleCells[index] ?? null;
|
||||
const personalCell = personalCells[index] ?? null;
|
||||
if (!singleCell || !personalCell) {
|
||||
const backendMetricsCell = backendMetricsCells[index] ?? null;
|
||||
if (!singleCell || !personalCell || !backendMetricsCell) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -263,6 +353,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
{
|
||||
authorId,
|
||||
authorName,
|
||||
backendMetricsCell,
|
||||
exportFields: readExportFieldsForDivGridRow(allHeaderCells, rowCells),
|
||||
hasDirectRatesSource:
|
||||
vueMarketRow?.hasDirectRatesSource ??
|
||||
serializedMarketRow?.hasDirectRatesSource ??
|
||||
@@ -324,7 +416,7 @@ function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement {
|
||||
return existingCell;
|
||||
}
|
||||
|
||||
const nextCell = row.ownerDocument.createElement("span");
|
||||
const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span");
|
||||
nextCell.dataset.marketRowCell = field;
|
||||
row.appendChild(nextCell);
|
||||
return nextCell;
|
||||
@@ -352,6 +444,7 @@ function ensureDivHeaderCell(
|
||||
const nextCell = cloneElementShallow(referenceCell);
|
||||
nextCell.dataset.marketHeaderCell = field;
|
||||
nextCell.textContent = label;
|
||||
applyColumnWidth(nextCell, field);
|
||||
container.insertBefore(nextCell, actionHeader);
|
||||
return nextCell;
|
||||
}
|
||||
@@ -378,6 +471,7 @@ function ensureDivBodyColumn(
|
||||
const referenceColumn = findPreviousColumn(actionColumn) ?? actionColumn;
|
||||
const nextColumn = cloneElementShallow(referenceColumn);
|
||||
nextColumn.dataset.marketColumnGroup = field;
|
||||
applyColumnWidth(nextColumn, field);
|
||||
syncDivColumnCells(nextColumn, actionColumn, field, rowCount);
|
||||
container.insertBefore(nextColumn, actionColumn);
|
||||
return nextColumn;
|
||||
@@ -407,6 +501,7 @@ function syncDivColumnCells(
|
||||
? cloneElementShallow(templateCell)
|
||||
: createBareContentCell(column.ownerDocument);
|
||||
nextCell.dataset.marketRowCell = field;
|
||||
applyColumnWidth(nextCell, field);
|
||||
nextCell.textContent = "";
|
||||
column.appendChild(nextCell);
|
||||
}
|
||||
@@ -420,6 +515,64 @@ function getOwnerDocument(root: ParentNode): Document | null {
|
||||
return root instanceof Document ? root : null;
|
||||
}
|
||||
|
||||
function readSyntheticHeaderLabels(header: HTMLElement): Record<string, string> {
|
||||
return Array.from(header.querySelectorAll("[data-market-header-cell]")).reduce<
|
||||
Record<string, string>
|
||||
>((labels, cell) => {
|
||||
if (!(cell instanceof header.ownerDocument.defaultView!.HTMLElement)) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
const field = cell.dataset.marketHeaderCell;
|
||||
if (!field) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
labels[field] = normalizeExportCellText(cell.textContent);
|
||||
return labels;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readSyntheticExportFields(
|
||||
row: HTMLElement,
|
||||
headerLabelsByField: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const exportFields: Record<string, string> = {};
|
||||
for (const cell of row.querySelectorAll("[data-market-field]")) {
|
||||
if (!(cell instanceof row.ownerDocument.defaultView!.HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = cell.dataset.marketField;
|
||||
const headerLabel = field ? headerLabelsByField[field] : "";
|
||||
if (!shouldExportColumn(headerLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
|
||||
}
|
||||
|
||||
return exportFields;
|
||||
}
|
||||
|
||||
function readExportFieldsForDivGridRow(
|
||||
headerCells: HTMLElement[],
|
||||
rowCells: HTMLElement[]
|
||||
): Record<string, string> {
|
||||
const exportFields: Record<string, string> = {};
|
||||
|
||||
rowCells.forEach((cell, index) => {
|
||||
const headerLabel = normalizeExportCellText(headerCells[index]?.textContent);
|
||||
if (!shouldExportColumn(headerLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
|
||||
});
|
||||
|
||||
return exportFields;
|
||||
}
|
||||
|
||||
function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null {
|
||||
let current = cell.previousElementSibling;
|
||||
while (current) {
|
||||
@@ -674,6 +827,116 @@ function normalizeMarketListRate(value: unknown): string | null {
|
||||
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
|
||||
}
|
||||
|
||||
function normalizeExportCellText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function shouldExportColumn(label: string): boolean {
|
||||
return Boolean(
|
||||
label &&
|
||||
label !== ACTION_HEADER_TEXT &&
|
||||
label !== BACKEND_HEADER_TEXT &&
|
||||
label !== "单视频看后搜率" &&
|
||||
label !== "个人视频看后搜率"
|
||||
);
|
||||
}
|
||||
|
||||
function readRateCellText(value: string | undefined): string {
|
||||
return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT;
|
||||
}
|
||||
|
||||
function applyColumnWidth(element: HTMLElement, field: string): void {
|
||||
if (field !== BACKEND_COLUMN_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.style.minWidth = "240px";
|
||||
element.style.width = "240px";
|
||||
}
|
||||
|
||||
function syncContainerWidth(container: Element | null): void {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directChildren = Array.from(container.children).filter(
|
||||
(child): child is HTMLElement => child instanceof HTMLElement
|
||||
);
|
||||
const totalWidth = directChildren.reduce((sum, child) => {
|
||||
return sum + readElementWidth(child);
|
||||
}, 0);
|
||||
|
||||
if (totalWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.width = `${totalWidth}px`;
|
||||
container.style.minWidth = `${totalWidth}px`;
|
||||
}
|
||||
|
||||
function readElementWidth(element: HTMLElement): number {
|
||||
const styleWidth = Number.parseFloat(element.style.width || "");
|
||||
if (Number.isFinite(styleWidth) && styleWidth > 0) {
|
||||
return styleWidth;
|
||||
}
|
||||
|
||||
const minWidth = Number.parseFloat(element.style.minWidth || "");
|
||||
if (Number.isFinite(minWidth) && minWidth > 0) {
|
||||
return minWidth;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function renderBackendMetricsCell(
|
||||
cell: HTMLElement,
|
||||
record: MarketRecord
|
||||
): void {
|
||||
if (
|
||||
record.backendMetricsStatus === "loading" ||
|
||||
(record.status === "loading" && !record.backendMetricsStatus)
|
||||
) {
|
||||
cell.textContent = "加载中...";
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.backendMetricsStatus === "failed") {
|
||||
cell.textContent = "加载失败";
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.backendMetricsStatus === "missing") {
|
||||
cell.textContent = UNAVAILABLE_BACKEND_METRICS_TEXT;
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.backendMetricsStatus !== "success" || !record.backendMetrics) {
|
||||
cell.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
cell.replaceChildren(createBackendMetricsPanel(cell.ownerDocument, record.backendMetrics));
|
||||
}
|
||||
|
||||
function createBackendMetricsPanel(
|
||||
document: Document,
|
||||
backendMetrics: BackendMetrics
|
||||
): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.dataset.marketBackendMetrics = "panel";
|
||||
|
||||
[
|
||||
["看后搜率", backendMetrics.afterViewSearchRate],
|
||||
["看后搜数", backendMetrics.afterViewSearchCount],
|
||||
["新增A3数", backendMetrics.a3IncreaseCount],
|
||||
["新增A3率", backendMetrics.newA3Rate],
|
||||
["CPA3", backendMetrics.cpa3],
|
||||
["cp_search", backendMetrics.cpSearch]
|
||||
].forEach(([label, value]) => {
|
||||
const item = document.createElement("div");
|
||||
item.textContent = `${label}${value ?? ""}`;
|
||||
panel.appendChild(item);
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
findNextPageControl,
|
||||
isPageControlDisabled,
|
||||
readMarketPageSignature
|
||||
} from "./dom-sync";
|
||||
import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types";
|
||||
|
||||
interface ExportRangeControllerOptions {
|
||||
document: Document;
|
||||
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
|
||||
prepareCurrentPageForExport(): Promise<void>;
|
||||
readCurrentPageRecords(): MarketRecord[];
|
||||
window: Window;
|
||||
}
|
||||
|
||||
export function createExportRangeController(options: ExportRangeControllerOptions) {
|
||||
return {
|
||||
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
|
||||
const mergedRecords = new Map<string, MarketRecord>();
|
||||
let currentPage = 0;
|
||||
let expectedMinimumRowCount: number | undefined;
|
||||
|
||||
while (true) {
|
||||
currentPage += 1;
|
||||
options.onProgress?.({
|
||||
currentPage,
|
||||
totalPages: target.mode === "count" ? target.pageCount : undefined
|
||||
});
|
||||
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
|
||||
if (!currentPageReady) {
|
||||
throw new Error(`第 ${currentPage} 页加载超时,请稍后重试`);
|
||||
}
|
||||
|
||||
await options.prepareCurrentPageForExport();
|
||||
const currentPageRecords = options.readCurrentPageRecords();
|
||||
currentPageRecords.forEach((record) => {
|
||||
const existingRecord = mergedRecords.get(record.authorId);
|
||||
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
|
||||
});
|
||||
expectedMinimumRowCount = Math.max(
|
||||
expectedMinimumRowCount ?? 0,
|
||||
currentPageRecords.length
|
||||
);
|
||||
|
||||
if (target.mode === "count" && currentPage >= target.pageCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const previousSignature = readMarketPageSignature(options.document);
|
||||
const nextPageControl = findNextPageControl(options.document);
|
||||
if (!nextPageControl || isPageControlDisabled(nextPageControl)) {
|
||||
break;
|
||||
}
|
||||
|
||||
nextPageControl.click();
|
||||
const pageChanged = await waitForPageChange(previousSignature);
|
||||
if (!pageChanged) {
|
||||
throw new Error(`第 ${currentPage + 1} 页导出失败,请稍后重试`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(mergedRecords.values());
|
||||
}
|
||||
};
|
||||
|
||||
async function waitForPageChange(previousSignature: string): Promise<boolean> {
|
||||
const previousPageState = parsePageSignature(previousSignature);
|
||||
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
options.window.setTimeout(resolve, 50);
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
const nextSignature = readMarketPageSignature(options.document);
|
||||
const nextPageState = parsePageSignature(nextSignature);
|
||||
if (hasLoadedNextPage(previousPageState, nextPageState)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForCurrentPageReady(
|
||||
expectedMinimumRowCount: number | undefined
|
||||
): Promise<boolean> {
|
||||
let stableAttemptCount = 0;
|
||||
let lastReadyFingerprint = "";
|
||||
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
options.window.setTimeout(resolve, 150);
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
const pageState = readCurrentPageState();
|
||||
if (!pageState.authorIds || pageState.rowCount <= 0) {
|
||||
stableAttemptCount = 0;
|
||||
lastReadyFingerprint = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof expectedMinimumRowCount === "number" &&
|
||||
expectedMinimumRowCount > 0 &&
|
||||
!pageState.isTerminalPage &&
|
||||
pageState.rowCount < expectedMinimumRowCount
|
||||
) {
|
||||
stableAttemptCount = 0;
|
||||
lastReadyFingerprint = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
const readyFingerprint = [
|
||||
pageState.pageToken,
|
||||
pageState.authorIds,
|
||||
String(pageState.rowCount),
|
||||
pageState.isTerminalPage ? "terminal" : "paged"
|
||||
].join("::");
|
||||
if (readyFingerprint === lastReadyFingerprint) {
|
||||
stableAttemptCount += 1;
|
||||
} else {
|
||||
lastReadyFingerprint = readyFingerprint;
|
||||
stableAttemptCount = 1;
|
||||
}
|
||||
|
||||
if (stableAttemptCount >= 6) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function readCurrentPageState(): {
|
||||
authorIds: string;
|
||||
isTerminalPage: boolean;
|
||||
pageToken: string;
|
||||
rowCount: number;
|
||||
} {
|
||||
const pageSignature = parsePageSignature(readMarketPageSignature(options.document));
|
||||
const nextPageControl = findNextPageControl(options.document);
|
||||
|
||||
return {
|
||||
authorIds: pageSignature.authorIds,
|
||||
isTerminalPage: isPageControlDisabled(nextPageControl),
|
||||
pageToken: pageSignature.pageToken,
|
||||
rowCount: options.readCurrentPageRecords().length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageSignature(signature: string): {
|
||||
authorIds: string;
|
||||
pageToken: string;
|
||||
} {
|
||||
const separatorIndex = signature.indexOf("::");
|
||||
if (separatorIndex < 0) {
|
||||
return {
|
||||
authorIds: "",
|
||||
pageToken: signature.trim()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authorIds: signature.slice(separatorIndex + 2).trim(),
|
||||
pageToken: signature.slice(0, separatorIndex).trim()
|
||||
};
|
||||
}
|
||||
|
||||
function hasLoadedNextPage(
|
||||
previousPageState: {
|
||||
authorIds: string;
|
||||
pageToken: string;
|
||||
},
|
||||
nextPageState: {
|
||||
authorIds: string;
|
||||
pageToken: string;
|
||||
}
|
||||
): boolean {
|
||||
if (!nextPageState.authorIds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextPageState.pageToken || previousPageState.pageToken) {
|
||||
return nextPageState.pageToken !== previousPageState.pageToken;
|
||||
}
|
||||
|
||||
return nextPageState.authorIds !== previousPageState.authorIds;
|
||||
}
|
||||
|
||||
function mergeMarketRecord(
|
||||
existingRecord: MarketRecord | undefined,
|
||||
incomingRecord: MarketRecord
|
||||
): MarketRecord {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
...incomingRecord,
|
||||
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
|
||||
rates: mergeFieldMap(undefined, incomingRecord.rates)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...existingRecord,
|
||||
...incomingRecord,
|
||||
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
|
||||
exportFields: mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
incomingRecord.exportFields
|
||||
),
|
||||
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
|
||||
hasDirectRatesSource:
|
||||
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
|
||||
location: mergeStringValue(existingRecord.location, incomingRecord.location),
|
||||
price21To60s: mergeStringValue(
|
||||
existingRecord.price21To60s,
|
||||
incomingRecord.price21To60s
|
||||
),
|
||||
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
|
||||
status: mergeStatus(existingRecord.status, incomingRecord.status)
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||
current: T | undefined,
|
||||
incoming: T | undefined
|
||||
): T | undefined {
|
||||
if (!current && !incoming) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...(current ?? {})
|
||||
} as Record<string, string | undefined>;
|
||||
|
||||
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||
const currentValue = merged[key];
|
||||
if (hasTextValue(value) || !hasTextValue(currentValue)) {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
function mergeStatus(
|
||||
current: MarketRecordStatus,
|
||||
incoming: MarketRecordStatus
|
||||
): MarketRecordStatus {
|
||||
const priority: Record<MarketRecordStatus, number> = {
|
||||
failed: 1,
|
||||
idle: 0,
|
||||
loading: 2,
|
||||
missing: -1,
|
||||
success: 3
|
||||
};
|
||||
|
||||
return priority[incoming] >= priority[current] ? incoming : current;
|
||||
}
|
||||
|
||||
function mergeStringValue(
|
||||
current: string | undefined,
|
||||
incoming: string | undefined
|
||||
): string | undefined {
|
||||
if (hasTextValue(incoming) || !hasTextValue(current)) {
|
||||
return incoming ?? current;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
+553
-96
@@ -1,4 +1,5 @@
|
||||
import { buildMarketCsv } from "./csv-exporter";
|
||||
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||
import {
|
||||
applyRowOrder,
|
||||
applyRowVisibility,
|
||||
@@ -7,24 +8,30 @@ import {
|
||||
type MarketRowDom
|
||||
} from "./dom-sync";
|
||||
import { applyFilterAndSort } from "./filter-sort-controller";
|
||||
import { createFullScanController } from "./full-scan-controller";
|
||||
import { createMarketApiClient } from "./api-client";
|
||||
import { createExportRangeController } from "./export-range-controller";
|
||||
import { ensurePluginToolbar } from "./plugin-toolbar";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
setToolbarBusyState,
|
||||
setToolbarExportStatus
|
||||
} from "./plugin-toolbar";
|
||||
import { createMarketResultStore } from "./result-store";
|
||||
import {
|
||||
isAuthResponseMessage,
|
||||
type AuthStateValue
|
||||
} from "../../shared/auth-messages";
|
||||
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
|
||||
import type {
|
||||
BackendMetrics,
|
||||
MarketApiResult,
|
||||
MarketFilterState,
|
||||
MarketExportTarget,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
MarketSortState
|
||||
} from "./types";
|
||||
|
||||
interface FullScanControllerLike {
|
||||
ensureScanForExport(): Promise<void>;
|
||||
ensureScanForFilter(): Promise<void>;
|
||||
ensureScanForSort(): Promise<void>;
|
||||
}
|
||||
|
||||
interface MutationObserverLike {
|
||||
disconnect(): void;
|
||||
observe(target: Node, options?: MutationObserverInit): void;
|
||||
@@ -33,41 +40,61 @@ interface MutationObserverLike {
|
||||
export interface CreateMarketControllerOptions {
|
||||
buildCsv?: (records: MarketRecord[]) => string;
|
||||
document: Document;
|
||||
fullScanController?: FullScanControllerLike;
|
||||
getAuthState?: () => Promise<AuthStateValue>;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
>;
|
||||
mutationObserverFactory?: (
|
||||
callback: MutationCallback
|
||||
) => MutationObserverLike;
|
||||
onCsvReady?: (csv: string) => void;
|
||||
promptBatchName?: () => string | null;
|
||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||
window: Window;
|
||||
}
|
||||
|
||||
export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const marketApiClient = createMarketApiClient();
|
||||
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||
const loadAuthorMetrics =
|
||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||
const searchBackendMetrics =
|
||||
options.searchBackendMetrics ??
|
||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
||||
const mutationObserverFactory =
|
||||
options.mutationObserverFactory ??
|
||||
((callback: MutationCallback) => new MutationObserver(callback));
|
||||
const promptBatchName =
|
||||
options.promptBatchName ??
|
||||
(() => options.window.prompt("请输入批次名称"));
|
||||
const submitBatch =
|
||||
options.submitBatch ??
|
||||
((payload: BatchPayload) =>
|
||||
readBatchSubmitAck(sendRuntimeMessage, payload));
|
||||
const exportRangeController = createExportRangeController({
|
||||
document: options.document,
|
||||
onProgress: ({ currentPage, totalPages }) => {
|
||||
setToolbarExportStatus(
|
||||
toolbar,
|
||||
totalPages
|
||||
? `导出中 ${currentPage}/${totalPages} 页...`
|
||||
: `导出中 第${currentPage}页...`
|
||||
);
|
||||
},
|
||||
prepareCurrentPageForExport: prepareCurrentPageForExport,
|
||||
readCurrentPageRecords: () => getVisibleOrderedRecords(),
|
||||
window: options.window
|
||||
});
|
||||
let activeFilters: MarketFilterState = {};
|
||||
let activeSort: MarketSortState | undefined;
|
||||
let isSyncRunning = false;
|
||||
let isSyncScheduled = false;
|
||||
let needsResync = false;
|
||||
|
||||
const fullScanController =
|
||||
options.fullScanController ??
|
||||
createFullScanController({
|
||||
goToNextPage: () => goToNextMarketPage(options.document, options.window),
|
||||
hasNextPage: () => hasNextMarketPage(options.document),
|
||||
loadAuthorMetrics,
|
||||
readCurrentPageRows: () =>
|
||||
readCurrentPageRows(options.document),
|
||||
resultStore
|
||||
});
|
||||
const observer = mutationObserverFactory(() => {
|
||||
scheduleSync();
|
||||
});
|
||||
@@ -89,18 +116,74 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
toolbar.singleFilterInput.value
|
||||
)
|
||||
};
|
||||
await fullScanController.ensureScanForFilter();
|
||||
applyCurrentView();
|
||||
},
|
||||
onApplySort: async () => {
|
||||
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
|
||||
await fullScanController.ensureScanForSort();
|
||||
applyCurrentView();
|
||||
},
|
||||
onExport: async () => {
|
||||
await fullScanController.ensureScanForExport();
|
||||
const records = getVisibleOrderedRecords();
|
||||
const exportTarget = readToolbarExportTarget(toolbar);
|
||||
if (!exportTarget.target) {
|
||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||
return;
|
||||
}
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const records = await exportRecords(exportTarget.target);
|
||||
options.onCsvReady?.(buildCsv(records));
|
||||
setToolbarExportStatus(toolbar, "");
|
||||
} catch (error) {
|
||||
setToolbarExportStatus(
|
||||
toolbar,
|
||||
error instanceof Error ? error.message : "导出失败,请稍后重试"
|
||||
);
|
||||
} finally {
|
||||
setToolbarBusyState(toolbar, false);
|
||||
}
|
||||
},
|
||||
onSubmitBatch: async () => {
|
||||
const exportTarget = readToolbarExportTarget(toolbar);
|
||||
if (!exportTarget.target) {
|
||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const batchName = promptBatchName();
|
||||
if (batchName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!batchName.trim()) {
|
||||
setToolbarExportStatus(toolbar, "请输入批次名称");
|
||||
return;
|
||||
}
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const records = await exportRecords(exportTarget.target, "提交中");
|
||||
const authState = await getAuthState();
|
||||
if (!authState.isAuthenticated) {
|
||||
throw new Error("请先登录插件");
|
||||
}
|
||||
|
||||
const payload = createBatchPayload({
|
||||
authState,
|
||||
batchName,
|
||||
createdAt: new Date().toISOString(),
|
||||
records
|
||||
});
|
||||
await submitBatch(payload);
|
||||
setToolbarExportStatus(toolbar, "批次提交成功");
|
||||
} catch (error) {
|
||||
setToolbarExportStatus(
|
||||
toolbar,
|
||||
error instanceof Error ? error.message : "批次提交失败,请稍后重试"
|
||||
);
|
||||
} finally {
|
||||
setToolbarBusyState(toolbar, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -119,12 +202,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageRows: Array<{
|
||||
rowDom: MarketRowDom;
|
||||
rowSnapshot: MarketRowSnapshot;
|
||||
}> = [];
|
||||
|
||||
for (const rowDom of table.rows) {
|
||||
const rowSnapshot = readRowSnapshot(rowDom);
|
||||
if (!rowSnapshot.authorId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pageRows.push({
|
||||
rowDom,
|
||||
rowSnapshot
|
||||
});
|
||||
resultStore.upsertMarketRow(rowSnapshot);
|
||||
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
|
||||
if (existingRecord?.status === "success" && existingRecord.rates) {
|
||||
@@ -187,6 +279,86 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
status: "failed"
|
||||
});
|
||||
}
|
||||
|
||||
await hydrateBackendMetricsForPage(pageRows);
|
||||
}
|
||||
|
||||
async function hydrateBackendMetricsForPage(
|
||||
pageRows: Array<{
|
||||
rowDom: MarketRowDom;
|
||||
rowSnapshot: MarketRowSnapshot;
|
||||
}>
|
||||
): Promise<void> {
|
||||
if (!searchBackendMetrics || pageRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingBackendRows = pageRows.filter(({ rowDom, rowSnapshot }) => {
|
||||
const record = resultStore.getRecord(rowSnapshot.authorId);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
record.backendMetricsStatus === "success" ||
|
||||
record.backendMetricsStatus === "missing" ||
|
||||
record.backendMetricsStatus === "failed" ||
|
||||
record.backendMetricsStatus === "loading"
|
||||
) {
|
||||
renderMarketRowState(rowDom, record);
|
||||
return false;
|
||||
}
|
||||
|
||||
resultStore.setBackendMetricsLoading(rowSnapshot.authorId);
|
||||
renderMarketRowState(rowDom, {
|
||||
...record,
|
||||
...rowSnapshot,
|
||||
backendMetricsStatus: "loading"
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
if (pendingBackendRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await searchBackendMetrics(
|
||||
pendingBackendRows.map(({ rowSnapshot }) => rowSnapshot.authorId)
|
||||
);
|
||||
const rowMap = new Map(rows.map((row) => [row.starId, row]));
|
||||
|
||||
pendingBackendRows.forEach(({ rowSnapshot }) => {
|
||||
const backendMetrics = rowMap.get(rowSnapshot.authorId);
|
||||
if (backendMetrics) {
|
||||
resultStore.setBackendMetricsSuccess(rowSnapshot.authorId, backendMetrics);
|
||||
} else {
|
||||
resultStore.setBackendMetricsMissing(rowSnapshot.authorId);
|
||||
}
|
||||
|
||||
const record = resultStore.getRecord(rowSnapshot.authorId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageRow = pendingBackendRows.find(
|
||||
(candidate) => candidate.rowSnapshot.authorId === rowSnapshot.authorId
|
||||
);
|
||||
if (pageRow) {
|
||||
renderMarketRowState(pageRow.rowDom, record);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
pendingBackendRows.forEach(({ rowDom, rowSnapshot }) => {
|
||||
resultStore.setBackendMetricsFailed(rowSnapshot.authorId);
|
||||
const record = resultStore.getRecord(rowSnapshot.authorId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderMarketRowState(rowDom, record);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyCurrentView(): void {
|
||||
@@ -195,18 +367,246 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = getVisibleOrderedRecords();
|
||||
const records = getVisibleOrderedRecords(table);
|
||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||
applyRowOrder(table, records.map((record) => record.authorId));
|
||||
}
|
||||
|
||||
function getVisibleOrderedRecords(): MarketRecord[] {
|
||||
return applyFilterAndSort(resultStore.listRecords(), {
|
||||
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
|
||||
const currentPageRecords = readCurrentPageRecords(table);
|
||||
|
||||
return applyFilterAndSort(currentPageRecords, {
|
||||
filters: activeFilters,
|
||||
sort: activeSort
|
||||
});
|
||||
}
|
||||
|
||||
async function exportRecords(
|
||||
target: MarketExportTarget,
|
||||
inProgressLabel = "导出中"
|
||||
): Promise<MarketRecord[]> {
|
||||
if (target.mode === "count" && target.pageCount <= 1) {
|
||||
setToolbarExportStatus(toolbar, `${inProgressLabel}...`);
|
||||
await prepareCurrentPageForExport();
|
||||
return getVisibleOrderedRecords();
|
||||
}
|
||||
|
||||
return exportRangeController.exportRecords(target);
|
||||
}
|
||||
|
||||
async function prepareCurrentPageForExport(): Promise<void> {
|
||||
await runSyncCycle();
|
||||
await harvestCurrentPageForExport();
|
||||
}
|
||||
|
||||
async function harvestCurrentPageForExport(): Promise<void> {
|
||||
await collectCurrentPageSnapshotsUntilSettled();
|
||||
|
||||
const table = syncMarketTable(options.document);
|
||||
const scrollContainer = findCurrentPageScrollContainer(table);
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalScrollTop = scrollContainer.scrollTop;
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight
|
||||
);
|
||||
if (maxScrollTop <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = Math.max(scrollContainer.clientHeight, 240);
|
||||
for (
|
||||
let nextScrollTop = Math.min(originalScrollTop + step, maxScrollTop);
|
||||
nextScrollTop > originalScrollTop && nextScrollTop <= maxScrollTop;
|
||||
nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop)
|
||||
) {
|
||||
setScrollTop(scrollContainer, nextScrollTop);
|
||||
await collectCurrentPageSnapshotsUntilSettled();
|
||||
|
||||
if (nextScrollTop === maxScrollTop) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (scrollContainer.scrollTop !== originalScrollTop) {
|
||||
setScrollTop(scrollContainer, originalScrollTop);
|
||||
await collectCurrentPageSnapshotsUntilSettled();
|
||||
}
|
||||
}
|
||||
|
||||
function readCurrentPageRecords(table: ReturnType<typeof syncMarketTable>): MarketRecord[] {
|
||||
if (!table) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return table.rows
|
||||
.map((rowDom) => {
|
||||
const rowSnapshot = readRowSnapshot(rowDom);
|
||||
if (!rowSnapshot.authorId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
|
||||
return {
|
||||
...existingRecord,
|
||||
...rowSnapshot,
|
||||
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
|
||||
backendMetrics: mergeFieldMap(
|
||||
existingRecord?.backendMetrics,
|
||||
rowSnapshot.backendMetrics
|
||||
),
|
||||
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
|
||||
exportFields: mergeFieldMap(
|
||||
existingRecord?.exportFields,
|
||||
rowSnapshot.exportFields
|
||||
),
|
||||
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
|
||||
price21To60s: mergeStringValue(
|
||||
existingRecord?.price21To60s,
|
||||
rowSnapshot.price21To60s
|
||||
),
|
||||
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
||||
status: existingRecord?.status ?? "idle"
|
||||
} satisfies MarketRecord;
|
||||
})
|
||||
.filter((record): record is MarketRecord => record !== null);
|
||||
}
|
||||
|
||||
function collectCurrentPageSnapshots(): void {
|
||||
readCurrentPageRows(options.document).forEach((rowSnapshot) => {
|
||||
resultStore.upsertMarketRow(rowSnapshot);
|
||||
});
|
||||
}
|
||||
|
||||
function findCurrentPageScrollContainer(
|
||||
table: ReturnType<typeof syncMarketTable>
|
||||
): HTMLElement | null {
|
||||
if (!table) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seenElements = new Set<HTMLElement>();
|
||||
const candidateRoots = table.rows
|
||||
.map((rowDom) => rowDom.row)
|
||||
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
|
||||
|
||||
for (const rootElement of candidateRoots) {
|
||||
let currentElement = rootElement.parentElement;
|
||||
while (currentElement) {
|
||||
if (
|
||||
!seenElements.has(currentElement) &&
|
||||
isScrollableContainer(currentElement)
|
||||
) {
|
||||
return currentElement;
|
||||
}
|
||||
|
||||
seenElements.add(currentElement);
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isScrollableContainer(element: HTMLElement): boolean {
|
||||
const computedStyle = options.window.getComputedStyle(element);
|
||||
return (
|
||||
/auto|scroll|overlay/.test(computedStyle.overflowY) &&
|
||||
element.scrollHeight > element.clientHeight
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForDomSettled(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
options.window.setTimeout(resolve, 0);
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
|
||||
let previousFingerprint = "";
|
||||
let stablePassCount = 0;
|
||||
|
||||
for (let attempt = 0; attempt < 9; attempt += 1) {
|
||||
await waitForDomSettled();
|
||||
if (attempt > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
options.window.setTimeout(
|
||||
resolve,
|
||||
previousFingerprint.includes("|missing:0") ? 25 : 50
|
||||
);
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
collectCurrentPageSnapshots();
|
||||
const hydrationSnapshot = readVisibleRowHydrationSnapshot();
|
||||
if (!hydrationSnapshot.fingerprint) {
|
||||
stablePassCount = 0;
|
||||
previousFingerprint = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hydrationSnapshot.fingerprint === previousFingerprint) {
|
||||
stablePassCount += 1;
|
||||
} else {
|
||||
previousFingerprint = hydrationSnapshot.fingerprint;
|
||||
stablePassCount = 1;
|
||||
}
|
||||
|
||||
if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readVisibleRowHydrationSnapshot(): {
|
||||
fingerprint: string;
|
||||
missingDefaultFieldCount: number;
|
||||
} {
|
||||
const table = syncMarketTable(options.document);
|
||||
if (!table || table.rows.length === 0) {
|
||||
return {
|
||||
fingerprint: "",
|
||||
missingDefaultFieldCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const parts = table.rows.map((rowDom) => {
|
||||
const rowSnapshot = readRowSnapshot(rowDom);
|
||||
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
||||
(value) => typeof value === "string" && value.trim().length > 0
|
||||
).length;
|
||||
const hasRepresentativeVideo = hasTextValue(
|
||||
rowSnapshot.exportFields?.["代表视频"]
|
||||
);
|
||||
const hasPriceField =
|
||||
hasTextValue(rowSnapshot.price21To60s) ||
|
||||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
|
||||
const missingDefaultFieldCount =
|
||||
Number(!hasRepresentativeVideo) + Number(!hasPriceField);
|
||||
|
||||
return [
|
||||
rowSnapshot.authorId,
|
||||
populatedFieldCount,
|
||||
hasRepresentativeVideo ? "video" : "no-video",
|
||||
hasPriceField ? "price" : "no-price",
|
||||
`missing:${missingDefaultFieldCount}`
|
||||
].join(":");
|
||||
});
|
||||
|
||||
return {
|
||||
fingerprint: parts.join("|"),
|
||||
missingDefaultFieldCount: parts.reduce((count, part) => {
|
||||
const match = part.match(/missing:(\d+)$/);
|
||||
return count + Number(match?.[1] ?? 0);
|
||||
}, 0)
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleSync(): void {
|
||||
if (isSyncRunning) {
|
||||
needsResync = true;
|
||||
@@ -245,6 +645,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
}
|
||||
|
||||
function setScrollTop(element: HTMLElement, top: number): void {
|
||||
element.scrollTop = top;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
|
||||
function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
|
||||
const table = syncMarketTable(document);
|
||||
if (!table) {
|
||||
@@ -260,6 +665,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||
return {
|
||||
authorId: rowDom.authorId,
|
||||
authorName: rowDom.authorName,
|
||||
exportFields: rowDom.exportFields,
|
||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||
price21To60s: rowDom.price21To60s,
|
||||
rates: rowDom.rates
|
||||
@@ -289,79 +695,130 @@ function readSortState(
|
||||
};
|
||||
}
|
||||
|
||||
function hasNextMarketPage(document: Document): boolean {
|
||||
const nextButton = findNextPageButton(document);
|
||||
return Boolean(nextButton && !isDisabled(nextButton));
|
||||
}
|
||||
|
||||
async function goToNextMarketPage(
|
||||
document: Document,
|
||||
window: Window
|
||||
): Promise<boolean> {
|
||||
const nextButton = findNextPageButton(document);
|
||||
if (!nextButton || isDisabled(nextButton)) {
|
||||
return false;
|
||||
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||
current: T | undefined,
|
||||
incoming: T | undefined
|
||||
): T | undefined {
|
||||
if (!current && !incoming) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousSignature = getCurrentPageSignature(document);
|
||||
nextButton.click();
|
||||
const merged = {
|
||||
...(current ?? {})
|
||||
} as Record<string, string | undefined>;
|
||||
|
||||
return waitForPageSignatureChange(document, window, previousSignature);
|
||||
}
|
||||
|
||||
function findNextPageButton(document: Document): HTMLElement | null {
|
||||
const selectorMatch = document.querySelector(
|
||||
'[data-testid="next-page"], .ant-pagination-next, .aux-pagination-next, .auxo-pagination-next, [aria-label="next page"]'
|
||||
);
|
||||
if (selectorMatch instanceof document.defaultView!.HTMLElement) {
|
||||
return selectorMatch;
|
||||
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||
const currentValue = merged[key];
|
||||
if (hasTextValue(value) || !hasTextValue(currentValue)) {
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return Array.from(document.querySelectorAll("button, a, div, span")).find(
|
||||
(element): element is HTMLElement =>
|
||||
element instanceof document.defaultView!.HTMLElement &&
|
||||
element.textContent?.trim() === "下一页"
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function isDisabled(element: HTMLElement): boolean {
|
||||
return (
|
||||
"disabled" in element &&
|
||||
Boolean((element as HTMLButtonElement).disabled) ||
|
||||
element.getAttribute("aria-disabled") === "true" ||
|
||||
/disabled|is-disabled/.test(element.className)
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentPageSignature(document: Document): string {
|
||||
return readCurrentPageRows(document)
|
||||
.map((row) => row.authorId)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function waitForPageSignatureChange(
|
||||
document: Document,
|
||||
window: Window,
|
||||
previousSignature: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const currentSignature = getCurrentPageSignature(document);
|
||||
if (currentSignature && currentSignature !== previousSignature) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt >= 5000) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(check, 50);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
|
||||
return (message: unknown) =>
|
||||
Promise.resolve(
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
runtime?: {
|
||||
sendMessage?: (payload: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome?.runtime?.sendMessage?.(message)
|
||||
);
|
||||
}
|
||||
|
||||
async function readAuthState(
|
||||
sendMessage: (message: unknown) => Promise<unknown>
|
||||
): Promise<AuthStateValue> {
|
||||
const response = await sendMessage({ type: "auth:get-state" });
|
||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||
throw new Error("请先登录插件");
|
||||
}
|
||||
|
||||
return response.value;
|
||||
}
|
||||
|
||||
async function readBatchSubmitAck(
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
payload: BatchPayload
|
||||
): Promise<unknown> {
|
||||
const response = await sendMessage({
|
||||
payload,
|
||||
type: "batch:submit"
|
||||
});
|
||||
|
||||
if (
|
||||
response &&
|
||||
typeof response === "object" &&
|
||||
(response as { ok?: unknown }).ok === true
|
||||
) {
|
||||
return (response as { value?: unknown }).value;
|
||||
}
|
||||
|
||||
if (
|
||||
response &&
|
||||
typeof response === "object" &&
|
||||
(response as { ok?: unknown }).ok === false &&
|
||||
typeof (response as { error?: unknown }).error === "string"
|
||||
) {
|
||||
throw new Error((response as { error: string }).error);
|
||||
}
|
||||
|
||||
throw new Error("批次提交失败,请稍后重试");
|
||||
}
|
||||
|
||||
async function readBackendMetrics(
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
starIds: string[]
|
||||
): Promise<Array<BackendMetrics & { starId: string }>> {
|
||||
const response = await sendMessage({
|
||||
type: "backend-metrics:search",
|
||||
value: {
|
||||
starIds
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
isBackendMetricsResponseMessage(response) &&
|
||||
response.ok &&
|
||||
response.type === "backend-metrics:result"
|
||||
) {
|
||||
return response.value.rows;
|
||||
}
|
||||
|
||||
throw new Error("后端指标加载失败");
|
||||
}
|
||||
|
||||
function mergeStringValue(
|
||||
current: string | undefined,
|
||||
incoming: string | undefined
|
||||
): string | undefined {
|
||||
if (hasTextValue(incoming) || !hasTextValue(current)) {
|
||||
return incoming ?? current;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasRuntimeMessageSender(): boolean {
|
||||
return Boolean(
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
runtime?: {
|
||||
sendMessage?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome?.runtime?.sendMessage
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { MarketExportScope, MarketExportTarget } from "./types";
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onApplyFilter(): Promise<void> | void;
|
||||
onApplySort(): Promise<void> | void;
|
||||
onExport(): Promise<void> | void;
|
||||
onSubmitBatch(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PluginToolbarDom {
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
filterApplyButton: HTMLButtonElement;
|
||||
personalFilterInput: HTMLInputElement;
|
||||
root: HTMLElement;
|
||||
@@ -65,6 +72,30 @@ export function ensurePluginToolbar(
|
||||
exportButton.dataset.pluginExport = "button";
|
||||
exportButton.textContent = "导出CSV";
|
||||
|
||||
const batchSubmitButton = document.createElement("button");
|
||||
batchSubmitButton.type = "button";
|
||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||
batchSubmitButton.textContent = "提交批次";
|
||||
|
||||
const exportRangeSelect = document.createElement("select");
|
||||
exportRangeSelect.dataset.pluginExportRange = "select";
|
||||
appendOption(exportRangeSelect, "current", "当前页");
|
||||
appendOption(exportRangeSelect, "first-5", "前5页");
|
||||
appendOption(exportRangeSelect, "first-10", "前10页");
|
||||
appendOption(exportRangeSelect, "all", "全部");
|
||||
appendOption(exportRangeSelect, "custom", "自定义");
|
||||
exportRangeSelect.value = "first-5";
|
||||
|
||||
const exportCustomPagesInput = document.createElement("input");
|
||||
exportCustomPagesInput.type = "number";
|
||||
exportCustomPagesInput.min = "1";
|
||||
exportCustomPagesInput.step = "1";
|
||||
exportCustomPagesInput.hidden = true;
|
||||
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
|
||||
|
||||
const exportStatusText = document.createElement("span");
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
|
||||
root.append(
|
||||
singleFilterInput,
|
||||
personalFilterInput,
|
||||
@@ -72,8 +103,12 @@ export function ensurePluginToolbar(
|
||||
sortFieldSelect,
|
||||
sortDirectionSelect,
|
||||
sortApplyButton,
|
||||
exportButton
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput,
|
||||
exportButton,
|
||||
batchSubmitButton
|
||||
);
|
||||
root.append(exportStatusText);
|
||||
document.body.prepend(root);
|
||||
|
||||
filterApplyButton.addEventListener("click", () => {
|
||||
@@ -85,9 +120,16 @@ export function ensurePluginToolbar(
|
||||
exportButton.addEventListener("click", () => {
|
||||
void handlers.onExport();
|
||||
});
|
||||
|
||||
return {
|
||||
batchSubmitButton.addEventListener("click", () => {
|
||||
void handlers.onSubmitBatch();
|
||||
});
|
||||
exportRangeSelect.addEventListener("change", () => {
|
||||
syncCustomPagesInputVisibility({
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
filterApplyButton,
|
||||
personalFilterInput,
|
||||
root,
|
||||
@@ -95,7 +137,26 @@ export function ensurePluginToolbar(
|
||||
sortApplyButton,
|
||||
sortDirectionSelect,
|
||||
sortFieldSelect
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const toolbarDom = {
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
filterApplyButton,
|
||||
personalFilterInput,
|
||||
root,
|
||||
singleFilterInput,
|
||||
sortApplyButton,
|
||||
sortDirectionSelect,
|
||||
sortFieldSelect
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
|
||||
return toolbarDom;
|
||||
}
|
||||
|
||||
function appendOption(
|
||||
@@ -110,10 +171,22 @@ function appendOption(
|
||||
}
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
return {
|
||||
const toolbarDom = {
|
||||
batchSubmitButton: root.querySelector(
|
||||
'[data-plugin-batch-submit="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportButton: root.querySelector(
|
||||
'[data-plugin-export="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportCustomPagesInput: root.querySelector(
|
||||
'[data-plugin-export-custom-pages="input"]'
|
||||
) as HTMLInputElement,
|
||||
exportRangeSelect: root.querySelector(
|
||||
'[data-plugin-export-range="select"]'
|
||||
) as HTMLSelectElement,
|
||||
exportStatusText: root.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement,
|
||||
filterApplyButton: root.querySelector(
|
||||
'[data-plugin-filter-apply="button"]'
|
||||
) as HTMLButtonElement,
|
||||
@@ -133,5 +206,94 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
sortFieldSelect: root.querySelector(
|
||||
'[data-plugin-sort-field="select"]'
|
||||
) as HTMLSelectElement
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
return toolbarDom;
|
||||
}
|
||||
|
||||
export function readToolbarExportTarget(
|
||||
toolbar: PluginToolbarDom
|
||||
): { error?: string; target?: MarketExportTarget } {
|
||||
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
|
||||
|
||||
if (scope === "all") {
|
||||
return {
|
||||
target: {
|
||||
mode: "all"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === "current") {
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === "first-5") {
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount: 5
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === "first-10") {
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount: 10
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pageCount = Number(toolbar.exportCustomPagesInput.value);
|
||||
if (!Number.isInteger(pageCount) || pageCount < 1) {
|
||||
return {
|
||||
error: "请输入有效页数"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setToolbarBusyState(
|
||||
toolbar: PluginToolbarDom,
|
||||
isBusy: boolean
|
||||
): void {
|
||||
[
|
||||
toolbar.batchSubmitButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.filterApplyButton,
|
||||
toolbar.sortApplyButton,
|
||||
toolbar.singleFilterInput,
|
||||
toolbar.personalFilterInput,
|
||||
toolbar.sortFieldSelect,
|
||||
toolbar.sortDirectionSelect,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput
|
||||
].forEach((element) => {
|
||||
element.disabled = isBusy;
|
||||
});
|
||||
}
|
||||
|
||||
export function setToolbarExportStatus(
|
||||
toolbar: PluginToolbarDom,
|
||||
text: string
|
||||
): void {
|
||||
toolbar.exportStatusText.textContent = text;
|
||||
}
|
||||
|
||||
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportCustomPagesInput.hidden =
|
||||
toolbar.exportRangeSelect.value !== "custom";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BackendMetrics,
|
||||
MarketApiFailureReason,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot
|
||||
@@ -25,6 +26,26 @@ export function createMarketResultStore() {
|
||||
existingRecord.status = "loading";
|
||||
delete existingRecord.failureReason;
|
||||
},
|
||||
setBackendMetricsFailed(authorId: string) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "failed";
|
||||
},
|
||||
setBackendMetricsLoading(authorId: string) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "loading";
|
||||
},
|
||||
setBackendMetricsMissing(authorId: string) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "missing";
|
||||
},
|
||||
setBackendMetricsSuccess(authorId: string, backendMetrics: BackendMetrics) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "success";
|
||||
existingRecord.backendMetrics = {
|
||||
...existingRecord.backendMetrics,
|
||||
...backendMetrics
|
||||
};
|
||||
},
|
||||
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.status = "success";
|
||||
@@ -37,19 +58,34 @@ export function createMarketResultStore() {
|
||||
upsertMarketRow(row: MarketRowSnapshot) {
|
||||
const existingRecord = records.get(row.authorId);
|
||||
if (existingRecord) {
|
||||
existingRecord.authorName =
|
||||
mergeStringValue(existingRecord.authorName, row.authorName) ??
|
||||
existingRecord.authorName;
|
||||
existingRecord.location = mergeStringValue(
|
||||
existingRecord.location,
|
||||
row.location
|
||||
);
|
||||
existingRecord.price21To60s = mergeStringValue(
|
||||
existingRecord.price21To60s,
|
||||
row.price21To60s
|
||||
);
|
||||
existingRecord.exportFields = mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
row.exportFields
|
||||
);
|
||||
existingRecord.backendMetrics = mergeFieldMap(
|
||||
existingRecord.backendMetrics,
|
||||
row.backendMetrics
|
||||
);
|
||||
existingRecord.hasDirectRatesSource =
|
||||
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
||||
if (row.rates) {
|
||||
existingRecord.rates = {
|
||||
...existingRecord.rates,
|
||||
...row.rates
|
||||
};
|
||||
}
|
||||
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
||||
return existingRecord;
|
||||
}
|
||||
|
||||
const nextRecord: MarketRecord = {
|
||||
...row,
|
||||
backendMetricsStatus: "idle",
|
||||
status: "idle"
|
||||
};
|
||||
records.set(row.authorId, nextRecord);
|
||||
@@ -66,9 +102,47 @@ export function createMarketResultStore() {
|
||||
const nextRecord: MarketRecord = {
|
||||
authorId,
|
||||
authorName: authorId,
|
||||
backendMetricsStatus: "idle",
|
||||
status: "idle"
|
||||
};
|
||||
records.set(authorId, nextRecord);
|
||||
return nextRecord;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||
current: T | undefined,
|
||||
incoming: T | undefined
|
||||
): T | undefined {
|
||||
if (!current && !incoming) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...(current ?? {})
|
||||
} as Record<string, string | undefined>;
|
||||
|
||||
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||
const currentValue = merged[key];
|
||||
if (!hasTextValue(currentValue)) {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
function mergeStringValue(
|
||||
current: string | undefined,
|
||||
incoming: string | undefined
|
||||
): string | undefined {
|
||||
if (!hasTextValue(current)) {
|
||||
return incoming ?? current;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,22 @@ export interface AfterSearchRates {
|
||||
singleVideoAfterSearchRate?: string;
|
||||
}
|
||||
|
||||
export interface BackendMetrics {
|
||||
a3IncreaseCount?: string;
|
||||
afterViewSearchCount?: string;
|
||||
afterViewSearchRate?: string;
|
||||
cpSearch?: string;
|
||||
cpa3?: string;
|
||||
newA3Rate?: string;
|
||||
}
|
||||
|
||||
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
|
||||
|
||||
export interface MarketRowSnapshot {
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
backendMetrics?: BackendMetrics;
|
||||
exportFields?: Record<string, string>;
|
||||
hasDirectRatesSource?: boolean;
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
@@ -15,6 +26,7 @@ export interface MarketRowSnapshot {
|
||||
}
|
||||
|
||||
export interface MarketRecord extends MarketRowSnapshot {
|
||||
backendMetricsStatus?: MarketRecordStatus;
|
||||
status: MarketRecordStatus;
|
||||
failureReason?: MarketApiFailureReason;
|
||||
}
|
||||
@@ -24,6 +36,17 @@ export interface MarketFilterState {
|
||||
singleVideoAfterSearchRateMin?: number;
|
||||
}
|
||||
|
||||
export type MarketExportScope = "current" | "first-5" | "first-10" | "all" | "custom";
|
||||
|
||||
export type MarketExportTarget =
|
||||
| {
|
||||
mode: "all";
|
||||
}
|
||||
| {
|
||||
mode: "count";
|
||||
pageCount: number;
|
||||
};
|
||||
|
||||
export interface MarketSortState {
|
||||
direction: "asc" | "desc";
|
||||
field: keyof Required<AfterSearchRates>;
|
||||
|
||||
+14
-1
@@ -1,8 +1,21 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Star Chart Search Enhancer",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0421.2",
|
||||
"description": "Bootstraps the Xingtu creator market content script.",
|
||||
"permissions": ["downloads", "identity", "storage"],
|
||||
"host_permissions": [
|
||||
"http://*/*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
"http://127.0.0.1:4319/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup/index.html"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background/index.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Star Chart Search Enhancer</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
renderDevPanel,
|
||||
renderLoggedIn,
|
||||
renderLoggedOut,
|
||||
setProtectedApiResult
|
||||
} from "./view";
|
||||
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
||||
import {
|
||||
isAuthResponseMessage,
|
||||
type AuthResponseMessage
|
||||
} from "../shared/auth-messages";
|
||||
import { createProtectedApiClient } from "../shared/protected-api-client";
|
||||
|
||||
interface BootPopupOptions {
|
||||
config?: Partial<AuthConfig>;
|
||||
document?: Document;
|
||||
fetchProtectedApi?: () => Promise<unknown>;
|
||||
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||
const currentDocument = options.document ?? document;
|
||||
const popupConfig = readAuthConfig(options.config);
|
||||
const root = currentDocument.querySelector("#app");
|
||||
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||
|
||||
if (!root || (HTMLElementCtor && !(root instanceof HTMLElementCtor))) {
|
||||
throw new Error("popup root #app is required");
|
||||
}
|
||||
|
||||
const sendMessage =
|
||||
options.sendMessage ??
|
||||
((message: unknown) =>
|
||||
Promise.resolve(
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
runtime?: {
|
||||
sendMessage?: (payload: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome?.runtime?.sendMessage?.(message)
|
||||
));
|
||||
const fetchProtectedApi =
|
||||
options.fetchProtectedApi ??
|
||||
createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
sendMessage
|
||||
}).loadProtectedMockData;
|
||||
|
||||
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
|
||||
}
|
||||
|
||||
async function renderCurrentAuthState(
|
||||
root: HTMLElement,
|
||||
popupConfig: AuthConfig,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
fetchProtectedApi: () => Promise<unknown>
|
||||
): Promise<void> {
|
||||
const response = await sendMessage({ type: "auth:get-state" });
|
||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||
renderLoggedOut(root, "认证状态读取失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.value.isAuthenticated) {
|
||||
renderLoggedOut(root, response.value.lastError);
|
||||
root
|
||||
.querySelector('[data-popup-sign-in="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runAuthAction(root, popupConfig, sendMessage, {
|
||||
actionMessage: { type: "auth:sign-in" },
|
||||
fetchProtectedApi
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
renderLoggedIn(root, response.value);
|
||||
root
|
||||
.querySelector('[data-popup-sign-out="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runAuthAction(root, popupConfig, sendMessage, {
|
||||
actionMessage: { type: "auth:sign-out" },
|
||||
fetchProtectedApi
|
||||
});
|
||||
});
|
||||
if (popupConfig.enableDevAuthPanel) {
|
||||
renderDevPanel(root, response.value);
|
||||
root
|
||||
.querySelector('[data-popup-test-protected-api="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runProtectedApiProbe(root, fetchProtectedApi);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runAuthAction(
|
||||
root: HTMLElement,
|
||||
popupConfig: AuthConfig,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
options: {
|
||||
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
||||
fetchProtectedApi: () => Promise<unknown>;
|
||||
}
|
||||
): Promise<void> {
|
||||
const response = await sendMessage(options.actionMessage);
|
||||
|
||||
if (isActionError(response)) {
|
||||
renderLoggedOut(root, response.error);
|
||||
root
|
||||
.querySelector('[data-popup-sign-in="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runAuthAction(root, popupConfig, sendMessage, options);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await renderCurrentAuthState(
|
||||
root,
|
||||
popupConfig,
|
||||
sendMessage,
|
||||
options.fetchProtectedApi
|
||||
);
|
||||
}
|
||||
|
||||
function isActionError(response: unknown): response is Extract<AuthResponseMessage, { ok: false }> {
|
||||
return (
|
||||
isAuthResponseMessage(response) &&
|
||||
!response.ok &&
|
||||
response.type === "auth:error"
|
||||
);
|
||||
}
|
||||
|
||||
async function runProtectedApiProbe(
|
||||
root: HTMLElement,
|
||||
fetchProtectedApi: () => Promise<unknown>
|
||||
): Promise<void> {
|
||||
setProtectedApiResult(root, "请求中...");
|
||||
|
||||
try {
|
||||
const result = await fetchProtectedApi();
|
||||
setProtectedApiResult(root, JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
setProtectedApiResult(
|
||||
root,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
void bootPopup();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AuthStateValue } from "../shared/auth-messages";
|
||||
|
||||
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
||||
root.innerHTML = `
|
||||
<section data-popup-state="logged-out">
|
||||
<h1>Star Chart Search Enhancer</h1>
|
||||
<p>登录后才能使用星图增强功能</p>
|
||||
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
||||
<button type="button" data-popup-sign-in="button">登录 Logto</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderLoggedIn(
|
||||
root: HTMLElement,
|
||||
authState: AuthStateValue
|
||||
): void {
|
||||
const userInfo = authState.userInfo;
|
||||
|
||||
root.innerHTML = `
|
||||
<section data-popup-state="logged-in">
|
||||
<h1>Star Chart Search Enhancer</h1>
|
||||
<p>已登录</p>
|
||||
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
||||
<p>${userInfo?.email ?? ""}</p>
|
||||
<button type="button" data-popup-sign-out="button">退出登录</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderDevPanel(
|
||||
root: HTMLElement,
|
||||
authState: AuthStateValue
|
||||
): void {
|
||||
const panel = root.ownerDocument.createElement("section");
|
||||
panel.dataset.popupDevPanel = "root";
|
||||
panel.innerHTML = `
|
||||
<h2>dev auth panel</h2>
|
||||
<p>resource: ${authState.resource ?? ""}</p>
|
||||
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
|
||||
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
||||
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
|
||||
<p>error: ${authState.lastError ?? ""}</p>
|
||||
<button type="button" data-popup-test-protected-api="button">测试受保护接口</button>
|
||||
<pre data-popup-protected-api-result="output"></pre>
|
||||
`;
|
||||
root.appendChild(panel);
|
||||
}
|
||||
|
||||
export function setProtectedApiResult(root: HTMLElement, value: string): void {
|
||||
const output = root.querySelector(
|
||||
'[data-popup-protected-api-result="output"]'
|
||||
);
|
||||
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.textContent = value;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface AuthConfig {
|
||||
apiResource: string;
|
||||
appId: string;
|
||||
enableDevAuthPanel: boolean;
|
||||
logtoEndpoint: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
const defaultAuthConfig: AuthConfig = {
|
||||
apiResource: "https://talent-search.intelligrow.cn",
|
||||
appId: "i4jkllbvih0554r4n0fd3",
|
||||
enableDevAuthPanel: true,
|
||||
logtoEndpoint: "https://login-api.intelligrow.cn",
|
||||
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
|
||||
};
|
||||
|
||||
export function readAuthConfig(
|
||||
overrides: Partial<AuthConfig> = {}
|
||||
): AuthConfig {
|
||||
const nextConfig = {
|
||||
...defaultAuthConfig,
|
||||
...overrides
|
||||
};
|
||||
|
||||
if (!nextConfig.logtoEndpoint.trim()) {
|
||||
throw new Error("auth config logtoEndpoint is required");
|
||||
}
|
||||
|
||||
if (!nextConfig.appId.trim()) {
|
||||
throw new Error("auth config appId is required");
|
||||
}
|
||||
|
||||
if (!nextConfig.apiResource.trim()) {
|
||||
throw new Error("auth config apiResource is required");
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export type AuthRequestMessage =
|
||||
| { type: "auth:get-state" }
|
||||
| { type: "auth:sign-in" }
|
||||
| { type: "auth:sign-out" }
|
||||
| { type: "auth:get-access-token" };
|
||||
|
||||
export interface AuthStateValue {
|
||||
accessTokenExpiresAt?: number | null;
|
||||
isAuthenticated: boolean;
|
||||
lastError?: string | null;
|
||||
resource?: string | null;
|
||||
scopes?: string[];
|
||||
tokenAvailable?: boolean;
|
||||
userInfo?: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
sub?: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AuthResponseMessage =
|
||||
| { ok: true; type: "auth:state"; value: AuthStateValue }
|
||||
| { ok: true; type: "auth:token"; value: { accessToken: string } }
|
||||
| { ok: true; type: "auth:ack" }
|
||||
| { ok: false; type: "auth:error"; error: string };
|
||||
|
||||
const authRequestTypes = new Set<AuthRequestMessage["type"]>([
|
||||
"auth:get-state",
|
||||
"auth:sign-in",
|
||||
"auth:sign-out",
|
||||
"auth:get-access-token"
|
||||
]);
|
||||
|
||||
export function isAuthRequestMessage(
|
||||
value: unknown
|
||||
): value is AuthRequestMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AuthRequestMessage>;
|
||||
return typeof candidate.type === "string" && authRequestTypes.has(candidate.type);
|
||||
}
|
||||
|
||||
export function isAuthResponseMessage(
|
||||
value: unknown
|
||||
): value is AuthResponseMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AuthResponseMessage>;
|
||||
if (candidate.ok === false) {
|
||||
return candidate.type === "auth:error" && typeof candidate.error === "string";
|
||||
}
|
||||
|
||||
if (candidate.ok !== true || typeof candidate.type !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.type === "auth:ack") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidate.type === "auth:token") {
|
||||
return Boolean(
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
typeof (candidate.value as { accessToken?: unknown }).accessToken === "string"
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type === "auth:state") {
|
||||
return Boolean(
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
typeof (candidate.value as { isAuthenticated?: unknown }).isAuthenticated ===
|
||||
"boolean"
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "./backend-metrics-config";
|
||||
|
||||
export interface BackendMetricsRow {
|
||||
a3IncreaseCount: string;
|
||||
afterViewSearchCount: string;
|
||||
afterViewSearchRate: string;
|
||||
cpSearch: string;
|
||||
cpa3: string;
|
||||
newA3Rate: string;
|
||||
starId: string;
|
||||
}
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
interface BackendMetricsClientOptions {
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
getAccessToken: () => Promise<string>;
|
||||
}
|
||||
|
||||
export function createBackendMetricsClient(options: BackendMetricsClientOptions) {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BACKEND_METRICS_BASE_URL;
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
|
||||
return {
|
||||
async searchByStarIds(starIds: string[]): Promise<BackendMetricsRow[]> {
|
||||
const response = await fetchImpl(buildBackendMetricsSearchUrl(baseUrl), {
|
||||
body: JSON.stringify(buildBackendMetricsSearchRequestBody(starIds)),
|
||||
headers: {
|
||||
Authorization: `Bearer ${await options.getAccessToken()}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("backend metrics request failed");
|
||||
}
|
||||
|
||||
return mapBackendMetricsSearchResponse(await response.json());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBackendMetricsSearchUrl(baseUrl: string): string {
|
||||
return new URL("/api/v1/history/talents/search", baseUrl).toString();
|
||||
}
|
||||
|
||||
export function buildBackendMetricsSearchRequestBody(starIds: string[]) {
|
||||
return {
|
||||
page: 1,
|
||||
size: Math.max(20, starIds.length),
|
||||
type: "star_id",
|
||||
values: starIds
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetricsRow[] {
|
||||
const rows = readResponseRows(payload);
|
||||
if (!rows) {
|
||||
throw new Error("backend metrics response is invalid");
|
||||
}
|
||||
|
||||
return rows.flatMap((row) => {
|
||||
if (!isRecord(row) || typeof row.star_id !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
a3IncreaseCount: formatDecimalValue(row.avg_a3_increase_cnt),
|
||||
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
|
||||
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
|
||||
cpSearch: formatDecimalValue(row.cp_search),
|
||||
cpa3: formatDecimalValue(row.cpa3),
|
||||
newA3Rate: formatRateValue(row.avg_new_a3_rate),
|
||||
starId: row.star_id
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function readResponseRows(payload: unknown): unknown[] | null {
|
||||
if (!isRecord(payload) || payload.success !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topLevelData = isRecord(payload.data) ? payload.data : null;
|
||||
return Array.isArray(topLevelData?.data) ? topLevelData.data : null;
|
||||
}
|
||||
|
||||
function formatRateValue(value: unknown): string {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
if (Number.isFinite(number)) {
|
||||
const percentage = number * 100;
|
||||
const formatted = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: percentage % 1 === 0 ? 0 : 2
|
||||
}).format(percentage);
|
||||
return `${formatted}%`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatDecimalValue(value: unknown): string {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2
|
||||
}).format(number);
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_BACKEND_METRICS_BASE_URL = "http://192.168.31.29:8083";
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { BackendMetricsRow } from "./backend-metrics-client";
|
||||
|
||||
export type BackendMetricsSearchRequestMessage = {
|
||||
type: "backend-metrics:search";
|
||||
value: {
|
||||
starIds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type BackendMetricsResponseMessage =
|
||||
| {
|
||||
ok: true;
|
||||
type: "backend-metrics:result";
|
||||
value: {
|
||||
rows: BackendMetricsRow[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
ok: false;
|
||||
type: "backend-metrics:error";
|
||||
};
|
||||
|
||||
export function isBackendMetricsSearchRequestMessage(
|
||||
value: unknown
|
||||
): value is BackendMetricsSearchRequestMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<BackendMetricsSearchRequestMessage>;
|
||||
return (
|
||||
candidate.type === "backend-metrics:search" &&
|
||||
Boolean(
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
Array.isArray((candidate.value as { starIds?: unknown }).starIds) &&
|
||||
(candidate.value as { starIds: unknown[] }).starIds.every(
|
||||
(starId) => typeof starId === "string"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isBackendMetricsResponseMessage(
|
||||
value: unknown
|
||||
): value is BackendMetricsResponseMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<BackendMetricsResponseMessage>;
|
||||
if (candidate.ok === false) {
|
||||
return (
|
||||
candidate.type === "backend-metrics:error" &&
|
||||
typeof candidate.error === "string"
|
||||
);
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
candidate.ok === true &&
|
||||
candidate.type === "backend-metrics:result" &&
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
Array.isArray((candidate.value as { rows?: unknown }).rows)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BatchPayload } from "../content/market/batch-payload";
|
||||
import { isAuthResponseMessage } from "./auth-messages";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
type GetAccessTokenLike = () => Promise<string>;
|
||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||
|
||||
export function createBatchSubmitClient(options: {
|
||||
baseUrl: string;
|
||||
fetchImpl?: FetchLike;
|
||||
getAccessToken?: GetAccessTokenLike;
|
||||
sendMessage: SendMessageLike;
|
||||
}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const getAccessToken =
|
||||
options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
|
||||
|
||||
return {
|
||||
async submitBatch(payload: BatchPayload) {
|
||||
const token = await getAccessToken();
|
||||
const response = await fetchImpl(
|
||||
new URL("/api/mock/batches", options.baseUrl).toString(),
|
||||
{
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("batch submit unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`batch submit failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||
|
||||
if (
|
||||
!isAuthResponseMessage(response) ||
|
||||
!response.ok ||
|
||||
response.type !== "auth:token" ||
|
||||
!response.value.accessToken.trim()
|
||||
) {
|
||||
throw new Error("batch submit token unavailable");
|
||||
}
|
||||
|
||||
return response.value.accessToken;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isAuthResponseMessage } from "./auth-messages";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||
|
||||
export function createProtectedApiClient(options: {
|
||||
baseUrl: string;
|
||||
fetchImpl?: FetchLike;
|
||||
sendMessage: SendMessageLike;
|
||||
}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
return {
|
||||
async loadProtectedMockData() {
|
||||
const token = await readAccessToken(options.sendMessage);
|
||||
const response = await fetchImpl(
|
||||
new URL("/api/mock/protected", options.baseUrl).toString(),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
method: "GET"
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("protected api unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`protected api request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||
|
||||
if (
|
||||
!isAuthResponseMessage(response) ||
|
||||
!response.ok ||
|
||||
response.type !== "auth:token" ||
|
||||
!response.value.accessToken.trim()
|
||||
) {
|
||||
throw new Error("protected api token unavailable");
|
||||
}
|
||||
|
||||
return response.value.accessToken;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { readAuthConfig } from "../src/shared/auth-config";
|
||||
|
||||
describe("auth-config", () => {
|
||||
test("returns the configured Logto settings", () => {
|
||||
expect(readAuthConfig()).toEqual({
|
||||
apiResource: "https://talent-search.intelligrow.cn",
|
||||
appId: "i4jkllbvih0554r4n0fd3",
|
||||
enableDevAuthPanel: true,
|
||||
logtoEndpoint: "https://login-api.intelligrow.cn",
|
||||
scopes: [
|
||||
"openid",
|
||||
"profile",
|
||||
"offline_access",
|
||||
"talent-search:read"
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects empty endpoint values", () => {
|
||||
expect(() =>
|
||||
readAuthConfig({
|
||||
logtoEndpoint: ""
|
||||
})
|
||||
).toThrow(/logtoEndpoint/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
isAuthRequestMessage,
|
||||
isAuthResponseMessage
|
||||
} from "../src/shared/auth-messages";
|
||||
|
||||
describe("auth-messages", () => {
|
||||
test("accepts a get-state request", () => {
|
||||
expect(isAuthRequestMessage({ type: "auth:get-state" })).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown auth requests", () => {
|
||||
expect(isAuthRequestMessage({ type: "auth:wat" })).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts a successful auth response envelope", () => {
|
||||
expect(
|
||||
isAuthResponseMessage({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../src/shared/backend-metrics-config";
|
||||
import {
|
||||
buildBackendMetricsSearchRequestBody,
|
||||
buildBackendMetricsSearchUrl,
|
||||
createBackendMetricsClient,
|
||||
mapBackendMetricsSearchResponse
|
||||
} from "../src/shared/backend-metrics-client";
|
||||
|
||||
describe("backend-metrics-client", () => {
|
||||
test("exports the default backend metrics base url", () => {
|
||||
expect(DEFAULT_BACKEND_METRICS_BASE_URL).toBe("http://192.168.31.29:8083");
|
||||
});
|
||||
|
||||
test("builds the backend search url", () => {
|
||||
expect(buildBackendMetricsSearchUrl("http://192.168.31.29:8083")).toBe(
|
||||
"http://192.168.31.29:8083/api/v1/history/talents/search"
|
||||
);
|
||||
});
|
||||
|
||||
test("builds a star_id batch request body", () => {
|
||||
expect(
|
||||
buildBackendMetricsSearchRequestBody(["7252982749131178039", "7290491710910496809"])
|
||||
).toEqual({
|
||||
page: 1,
|
||||
size: 20,
|
||||
type: "star_id",
|
||||
values: ["7252982749131178039", "7290491710910496809"]
|
||||
});
|
||||
});
|
||||
|
||||
test("maps backend metrics rows into display-ready values", () => {
|
||||
expect(
|
||||
mapBackendMetricsSearchResponse({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
avg_a3_increase_cnt: 78366.22448979592,
|
||||
avg_after_view_search_cnt: 9689.959183673469,
|
||||
avg_after_view_search_rate: 0.0036203703369054683,
|
||||
avg_new_a3_rate: 0.034428135017531614,
|
||||
cp_search: 14.460581961550774,
|
||||
cpa3: 1.788046443373538,
|
||||
star_id: "7252982749131178039"
|
||||
}
|
||||
]
|
||||
},
|
||||
success: true
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
a3IncreaseCount: "78,366.22",
|
||||
afterViewSearchCount: "9,689.96",
|
||||
afterViewSearchRate: "0.36%",
|
||||
cpSearch: "14.46",
|
||||
cpa3: "1.79",
|
||||
newA3Rate: "3.44%",
|
||||
starId: "7252982749131178039"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("posts star ids with bearer auth when searching backend metrics", async () => {
|
||||
const fetchImpl = async (_input: string, init?: RequestInit) => ({
|
||||
json: async () => ({
|
||||
data: {
|
||||
data: []
|
||||
},
|
||||
success: true
|
||||
}),
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "http://192.168.31.29:8083/api/v1/history/talents/search"
|
||||
});
|
||||
const fetchSpy = vi.fn(fetchImpl);
|
||||
const client = createBackendMetricsClient({
|
||||
fetchImpl: fetchSpy,
|
||||
getAccessToken: async () => "test-token"
|
||||
});
|
||||
|
||||
await client.searchByStarIds(["111", "222"]);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://192.168.31.29:8083/api/v1/history/talents/search",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
page: 1,
|
||||
size: 20,
|
||||
type: "star_id",
|
||||
values: ["111", "222"]
|
||||
}),
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
isBackendMetricsResponseMessage,
|
||||
isBackendMetricsSearchRequestMessage
|
||||
} from "../src/shared/backend-metrics-messages";
|
||||
|
||||
describe("backend-metrics-messages", () => {
|
||||
test("accepts a backend metrics search request", () => {
|
||||
expect(
|
||||
isBackendMetricsSearchRequestMessage({
|
||||
type: "backend-metrics:search",
|
||||
value: {
|
||||
starIds: ["111", "222"]
|
||||
}
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts a successful backend metrics response", () => {
|
||||
expect(
|
||||
isBackendMetricsResponseMessage({
|
||||
ok: true,
|
||||
type: "backend-metrics:result",
|
||||
value: {
|
||||
rows: [
|
||||
{
|
||||
a3IncreaseCount: "10.00",
|
||||
afterViewSearchCount: "20.00",
|
||||
afterViewSearchRate: "0.20%",
|
||||
cpSearch: "1.10",
|
||||
cpa3: "2.20",
|
||||
newA3Rate: "1.50%",
|
||||
starId: "111"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createLogtoAuthClient } from "../src/background/auth/client";
|
||||
|
||||
vi.mock("@logto/chrome-extension", () => {
|
||||
const signIn = vi.fn(async () => undefined);
|
||||
const signOut = vi.fn(async () => undefined);
|
||||
const MockLogtoClient = vi.fn(function MockLogtoClient() {
|
||||
return {
|
||||
getAccessToken: vi.fn(async () => "token"),
|
||||
getIdTokenClaims: vi.fn(async () => null),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
signIn,
|
||||
signOut
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
default: MockLogtoClient
|
||||
};
|
||||
});
|
||||
|
||||
describe("background-auth-client", () => {
|
||||
test("uses chrome identity redirect URLs for sign in and sign out", async () => {
|
||||
const getRedirectURL = vi.fn((path?: string) =>
|
||||
path ? `https://extension.chromiumapp.org${path}` : "https://extension.chromiumapp.org/"
|
||||
);
|
||||
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
identity?: {
|
||||
getRedirectURL?: (path?: string) => string;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome = {
|
||||
identity: {
|
||||
getRedirectURL
|
||||
}
|
||||
};
|
||||
|
||||
const authClient = createLogtoAuthClient();
|
||||
await authClient.signIn();
|
||||
await authClient.signOut();
|
||||
|
||||
expect(getRedirectURL).toHaveBeenNthCalledWith(1, "/callback");
|
||||
expect(getRedirectURL).toHaveBeenNthCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createAuthController } from "../src/background/auth/controller";
|
||||
|
||||
describe("background-auth-controller", () => {
|
||||
test("returns unauthenticated state when the client is logged out", async () => {
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await expect(controller.getAuthState()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isAuthenticated: false
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("delegates sign in to the auth client", async () => {
|
||||
const signIn = vi.fn(async () => undefined);
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
signIn,
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await controller.signIn();
|
||||
|
||||
expect(signIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { registerBackgroundMessageHandler } from "../src/background/index";
|
||||
|
||||
describe("background-index", () => {
|
||||
test("downloads csv files when the content script sends a download message", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const download = vi.fn(async () => undefined);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler({
|
||||
downloads: {
|
||||
download
|
||||
},
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(listeners).toHaveLength(1);
|
||||
const result = listeners[0](
|
||||
{
|
||||
csv: "列1,列2\n值1,值2",
|
||||
filename: "market.csv",
|
||||
type: "download-market-csv"
|
||||
},
|
||||
{},
|
||||
sendResponse
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
expect(download).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: "market.csv",
|
||||
saveAs: false,
|
||||
url: expect.stringContaining("data:text/csv;charset=utf-8,")
|
||||
})
|
||||
);
|
||||
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
test("responds to auth:get-state with auth status", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(),
|
||||
getAuthState: vi.fn(async () => ({ isAuthenticated: false })),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
});
|
||||
});
|
||||
|
||||
test("responds to auth:get-access-token with the current token", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(async () => "test-access-token"),
|
||||
getAuthState: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0](
|
||||
{ type: "auth:get-access-token" },
|
||||
{},
|
||||
sendResponse
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "test-access-token" }
|
||||
});
|
||||
});
|
||||
|
||||
test("submits batches through the background message handler", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const sendResponse = vi.fn();
|
||||
const submitBatch = vi.fn(async () => ({
|
||||
acceptedCount: 1,
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
ok: true
|
||||
}));
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(async () => "test-access-token"),
|
||||
getAuthState: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
},
|
||||
submitBatch
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0](
|
||||
{
|
||||
payload: {
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
},
|
||||
type: "batch:submit"
|
||||
},
|
||||
{},
|
||||
sendResponse
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(submitBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z"
|
||||
})
|
||||
);
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "batch:ack",
|
||||
value: {
|
||||
acceptedCount: 1,
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
ok: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("searches backend metrics through the background message handler", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const sendResponse = vi.fn();
|
||||
const searchBackendMetrics = vi.fn(async () => [
|
||||
{
|
||||
a3IncreaseCount: "78,366.22",
|
||||
afterViewSearchCount: "9,689.96",
|
||||
afterViewSearchRate: "0.36%",
|
||||
cpSearch: "14.46",
|
||||
cpa3: "1.79",
|
||||
newA3Rate: "3.44%",
|
||||
starId: "111"
|
||||
}
|
||||
]);
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(async () => "test-access-token"),
|
||||
getAuthState: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
},
|
||||
searchBackendMetrics
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0](
|
||||
{
|
||||
type: "backend-metrics:search",
|
||||
value: {
|
||||
starIds: ["111", "222"]
|
||||
}
|
||||
},
|
||||
{},
|
||||
sendResponse
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(searchBackendMetrics).toHaveBeenCalledWith(["111", "222"]);
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "backend-metrics:result",
|
||||
value: {
|
||||
rows: [
|
||||
{
|
||||
a3IncreaseCount: "78,366.22",
|
||||
afterViewSearchCount: "9,689.96",
|
||||
afterViewSearchRate: "0.36%",
|
||||
cpSearch: "14.46",
|
||||
cpa3: "1.79",
|
||||
newA3Rate: "3.44%",
|
||||
starId: "111"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createBatchPayload } from "../src/content/market/batch-payload";
|
||||
|
||||
describe("batch-payload", () => {
|
||||
test("builds a batch id from the batch name and timestamp", () => {
|
||||
const payload = createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: {
|
||||
name: "王少卿",
|
||||
sub: "p7pdhhtde8kj"
|
||||
}
|
||||
},
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
records: [
|
||||
{ authorId: "111", authorName: "达人A", status: "success" },
|
||||
{ authorId: "222", authorName: "达人B", status: "success" }
|
||||
]
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
authors: [
|
||||
{ authorId: "111", authorName: "达人A" },
|
||||
{ authorId: "222", authorName: "达人B" }
|
||||
],
|
||||
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when the user id is unavailable", () => {
|
||||
expect(() =>
|
||||
createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: {
|
||||
name: "王少卿"
|
||||
}
|
||||
},
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
records: [{ authorId: "111", authorName: "达人A", status: "success" }]
|
||||
})
|
||||
).toThrow(/user/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
||||
|
||||
describe("batch-submit-client", () => {
|
||||
test("posts the batch payload with a Bearer token", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}));
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ acceptedCount: 2, ok: true })
|
||||
}));
|
||||
|
||||
const client = createBatchSubmitClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await client.submitBatch({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:4319/api/mock/batches",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
}),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer abc123",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
method: "POST"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws on unauthorized responses", async () => {
|
||||
const client = createBatchSubmitClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: "unauthorized", ok: false })
|
||||
})),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}))
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.submitBatch({
|
||||
authors: [],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
})
|
||||
).rejects.toThrow(/unauthorized/i);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { buildMarketCsv } from "../src/content/market/csv-exporter";
|
||||
import type { MarketRecord } from "../src/content/market/types";
|
||||
|
||||
describe("csv-exporter", () => {
|
||||
test("uses the expected header order", () => {
|
||||
test("uses fallback header order when no page export fields are available", () => {
|
||||
const csv = buildMarketCsv([]);
|
||||
const [headerLine] = csv.split("\n");
|
||||
|
||||
@@ -15,12 +15,38 @@ describe("csv-exporter", () => {
|
||||
"地区",
|
||||
"21-60s报价",
|
||||
"单视频看后搜率",
|
||||
"个人视频看后搜率",
|
||||
"插件数据状态"
|
||||
"个人视频看后搜率"
|
||||
].join(",")
|
||||
);
|
||||
});
|
||||
|
||||
test("uses page export field order and appends the two plugin columns", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
authorId: "123",
|
||||
authorName: "Alice",
|
||||
exportFields: {
|
||||
达人信息: "Alice",
|
||||
粉丝数: "100w",
|
||||
"21-60s报价": "¥450,000"
|
||||
},
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
personalVideoAfterSearchRate: "1% - 3%"
|
||||
}
|
||||
} satisfies MarketRecord
|
||||
]);
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
expect(headerLine).toBe(
|
||||
["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join(
|
||||
","
|
||||
)
|
||||
);
|
||||
expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%');
|
||||
});
|
||||
|
||||
test("escapes commas and quotes", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
@@ -51,7 +77,7 @@ describe("csv-exporter", () => {
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
expect(rowLine).toBe("123,Alice,,,,,failed");
|
||||
expect(rowLine).toBe("123,Alice,,,,");
|
||||
});
|
||||
|
||||
test("uses normalized display values in export rows", () => {
|
||||
|
||||
@@ -10,4 +10,20 @@ describe("manifest", () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test("declares the downloads and auth permissions plus background worker", () => {
|
||||
expect(manifest.permissions).toEqual(
|
||||
expect.arrayContaining(["downloads", "identity", "storage"])
|
||||
);
|
||||
expect(manifest.host_permissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
"http://*/*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
"http://127.0.0.1:4319/*",
|
||||
"https://*/*"
|
||||
])
|
||||
);
|
||||
expect(manifest.background?.service_worker).toBe("background/index.js");
|
||||
expect(manifest.action?.default_popup).toBe("popup/index.html");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url":"https://xingtu.cn/ad/creator/market"}
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootContentScript } from "../src/content/index";
|
||||
|
||||
describe("market-auth-gating", () => {
|
||||
test("shows a login gate instead of booting the market controller when unauthenticated", async () => {
|
||||
document.body.innerHTML = "<div></div>";
|
||||
|
||||
const createMarketController = vi.fn();
|
||||
await bootContentScript({
|
||||
createMarketController,
|
||||
document,
|
||||
sendAuthMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
})),
|
||||
window
|
||||
});
|
||||
|
||||
expect(createMarketController).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("请先登录插件");
|
||||
});
|
||||
});
|
||||
+1334
-62
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ import { beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
applyRowOrder,
|
||||
applyRowVisibility,
|
||||
findNextPageControl,
|
||||
isPageControlDisabled,
|
||||
readMarketPageSignature,
|
||||
renderMarketRowState,
|
||||
syncMarketTable
|
||||
} from "../src/content/market/dom-sync";
|
||||
@@ -44,10 +47,13 @@ describe("market-dom-sync", () => {
|
||||
'[data-market-header-cell="personalVideoAfterSearchRate"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(4);
|
||||
expect(
|
||||
document.querySelector('[data-market-header-cell="backendMetrics"]')
|
||||
).not.toBeNull();
|
||||
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(6);
|
||||
});
|
||||
|
||||
test("renders loading, success, and failed states", () => {
|
||||
test("renders loading, success, missing, and failed states", () => {
|
||||
const table = syncMarketTable(document);
|
||||
if (!table) {
|
||||
throw new Error("Expected market table");
|
||||
@@ -64,6 +70,15 @@ describe("market-dom-sync", () => {
|
||||
renderMarketRowState(betaRow, {
|
||||
authorId: "b",
|
||||
authorName: "Beta",
|
||||
backendMetrics: {
|
||||
a3IncreaseCount: "78,366.22",
|
||||
afterViewSearchCount: "9,689.96",
|
||||
afterViewSearchRate: "0.36%",
|
||||
cpSearch: "14.46",
|
||||
cpa3: "1.79",
|
||||
newA3Rate: "3.44%"
|
||||
},
|
||||
backendMetricsStatus: "success",
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
@@ -72,16 +87,34 @@ describe("market-dom-sync", () => {
|
||||
});
|
||||
|
||||
expect(alphaRow.singleCell.textContent).toBe("加载中...");
|
||||
expect(alphaRow.backendMetricsCell.textContent).toBe("加载中...");
|
||||
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
|
||||
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
|
||||
expect(betaRow.backendMetricsCell.textContent).toContain("看后搜率");
|
||||
expect(betaRow.backendMetricsCell.textContent).toContain("0.36%");
|
||||
expect(betaRow.backendMetricsCell.textContent).toContain("CPA3");
|
||||
|
||||
renderMarketRowState(betaRow, {
|
||||
authorId: "b",
|
||||
authorName: "Beta",
|
||||
backendMetricsStatus: "missing",
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
personalVideoAfterSearchRate: "0.02 - 0.1%"
|
||||
}
|
||||
});
|
||||
expect(betaRow.backendMetricsCell.textContent).toBe("暂无数据");
|
||||
|
||||
renderMarketRowState(betaRow, {
|
||||
authorId: "b",
|
||||
authorName: "Beta",
|
||||
backendMetricsStatus: "failed",
|
||||
status: "failed"
|
||||
});
|
||||
expect(betaRow.singleCell.textContent).toBe("加载失败");
|
||||
expect(betaRow.personalCell.textContent).toBe("加载失败");
|
||||
expect(betaRow.backendMetricsCell.textContent).toBe("加载失败");
|
||||
});
|
||||
|
||||
test("hides rows outside the visible author ids", () => {
|
||||
@@ -123,13 +156,37 @@ describe("market-dom-sync", () => {
|
||||
"21-60s报价",
|
||||
"单视频看后搜率",
|
||||
"个人视频看后搜率",
|
||||
"秒探指标",
|
||||
"操作"
|
||||
]);
|
||||
expect(
|
||||
Number.parseFloat(
|
||||
(
|
||||
document.querySelector('[data-testid="right-header"]') as HTMLElement
|
||||
).style.width
|
||||
)
|
||||
).toBeGreaterThan(350);
|
||||
expect(
|
||||
Number.parseFloat(
|
||||
(
|
||||
document.querySelector('[data-testid="right-section"]') as HTMLElement
|
||||
).style.width
|
||||
)
|
||||
).toBeGreaterThan(350);
|
||||
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
||||
|
||||
renderMarketRowState(table.rows[0], {
|
||||
authorId: "111",
|
||||
authorName: "达人 A",
|
||||
backendMetrics: {
|
||||
a3IncreaseCount: "78,366.22",
|
||||
afterViewSearchCount: "9,689.96",
|
||||
afterViewSearchRate: "0.36%",
|
||||
cpSearch: "14.46",
|
||||
cpa3: "1.79",
|
||||
newA3Rate: "3.44%"
|
||||
},
|
||||
backendMetricsStatus: "success",
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
@@ -141,6 +198,7 @@ describe("market-dom-sync", () => {
|
||||
"¥450,000",
|
||||
"0.5% - 1%",
|
||||
"0.02% - 0.1%",
|
||||
"看后搜率0.36%看后搜数9,689.96新增A3数78,366.22新增A3率3.44%CPA31.79cp_search14.46",
|
||||
"下单"
|
||||
]);
|
||||
|
||||
@@ -153,7 +211,12 @@ describe("market-dom-sync", () => {
|
||||
applyRowOrder(table, ["222", "111"]);
|
||||
|
||||
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
|
||||
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]);
|
||||
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "", "下单"]);
|
||||
expect(table.rows[0].exportFields).toMatchObject({
|
||||
"21-60s报价": "¥450,000",
|
||||
"代表视频": "代表视频A",
|
||||
"达人信息": "达人 A"
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to the market vue state when the DOM has no author id", () => {
|
||||
@@ -208,6 +271,33 @@ describe("market-dom-sync", () => {
|
||||
singleVideoAfterSearchRate: "0.02%"
|
||||
});
|
||||
});
|
||||
|
||||
test("finds the real next-page button in Xingtu pagination", () => {
|
||||
document.body.innerHTML = `
|
||||
${buildRealMarketGridFixture()}
|
||||
<div class="pagination xt-space xt-space--medium">
|
||||
<div class="el-pagination is-background xt-pagination xt-pagination--normal">
|
||||
<button type="button" disabled="disabled" class="btn-prev">
|
||||
<i class="el-icon el-icon-arrow-left"></i>
|
||||
</button>
|
||||
<ul class="el-pager">
|
||||
<li class="number active">1</li>
|
||||
<li class="number">2</li>
|
||||
</ul>
|
||||
<button type="button" class="btn-next">
|
||||
<i class="el-icon el-icon-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const nextControl = findNextPageControl(document);
|
||||
|
||||
expect(nextControl).not.toBeNull();
|
||||
expect(nextControl?.className).toContain("btn-next");
|
||||
expect(isPageControlDisabled(nextControl)).toBe(false);
|
||||
expect(readMarketPageSignature(document)).toContain("1::111|222");
|
||||
});
|
||||
});
|
||||
|
||||
function buildRealMarketGridFixture() {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createMockProtectedApiServer } from "../scripts/mock-protected-api.mjs";
|
||||
|
||||
const servers: Array<{ close: () => Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (servers.length > 0) {
|
||||
await servers.pop()?.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("mock-protected-api", () => {
|
||||
test("returns mock data when a Bearer token is present", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
servers.push(server);
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/protected`, {
|
||||
headers: {
|
||||
Authorization: "Bearer abc123"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
source: "mock-protected-api"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 401 when the Authorization header is missing", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
servers.push(server);
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/protected`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "unauthorized"
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts a batch payload when a Bearer token is present", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
servers.push(server);
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
|
||||
body: JSON.stringify({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
}),
|
||||
headers: {
|
||||
Authorization: "Bearer abc123",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
acceptedCount: 1,
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
ok: true,
|
||||
source: "mock-batch-submit"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootPopup } from "../src/popup/index";
|
||||
|
||||
describe("popup-entry", () => {
|
||||
let dom: JSDOM;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
});
|
||||
|
||||
test("renders a sign-in button when unauthenticated", async () => {
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
document: dom.window.document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
}))
|
||||
});
|
||||
|
||||
expect(dom.window.document.querySelector("button")?.textContent).toContain(
|
||||
"登录"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders the dev auth panel when enabled", async () => {
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document: dom.window.document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
accessTokenExpiresAt: 1700000000000,
|
||||
isAuthenticated: true,
|
||||
resource: "https://api.example.test",
|
||||
scopes: ["openid", "profile"],
|
||||
tokenAvailable: true,
|
||||
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
expect(dom.window.document.body.textContent).toContain("resource");
|
||||
expect(dom.window.document.body.textContent).toContain("token");
|
||||
});
|
||||
|
||||
test("renders a protected api test button in the dev panel", async () => {
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document: dom.window.document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
tokenAvailable: true
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-test-protected-api="button"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("clicking the dev button runs the protected api client and prints the result", async () => {
|
||||
const fetchProtectedApi = vi.fn(async () => ({
|
||||
message: "authorized",
|
||||
ok: true,
|
||||
source: "mock-protected-api"
|
||||
}));
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
tokenAvailable: true
|
||||
}
|
||||
}));
|
||||
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document: dom.window.document,
|
||||
fetchProtectedApi,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
(
|
||||
dom.window.document.querySelector(
|
||||
'[data-popup-test-protected-api="button"]'
|
||||
) as HTMLButtonElement | null
|
||||
)?.click();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchProtectedApi).toHaveBeenCalledTimes(1);
|
||||
expect(dom.window.document.body.textContent).toContain("authorized");
|
||||
expect(dom.window.document.body.textContent).toContain("mock-protected-api");
|
||||
});
|
||||
|
||||
test("clicking sign-out sends the auth:sign-out message", async () => {
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
type: "auth:ack"
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: false
|
||||
}
|
||||
});
|
||||
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
document: dom.window.document,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
(
|
||||
dom.window.document.querySelector('[data-popup-sign-out="button"]') as
|
||||
| HTMLButtonElement
|
||||
| null
|
||||
)?.click();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
|
||||
});
|
||||
|
||||
test("shows the auth error when sign-in fails", async () => {
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: false
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
error: "redirect_uri_mismatch",
|
||||
ok: false,
|
||||
type: "auth:error"
|
||||
});
|
||||
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
document: dom.window.document,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
(
|
||||
dom.window.document.querySelector('[data-popup-sign-in="button"]') as
|
||||
| HTMLButtonElement
|
||||
| null
|
||||
)?.click();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(dom.window.document.body.textContent).toContain(
|
||||
"redirect_uri_mismatch"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createProtectedApiClient } from "../src/shared/protected-api-client";
|
||||
|
||||
describe("protected-api-client", () => {
|
||||
test("requests a token before calling the protected endpoint", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}));
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true })
|
||||
}));
|
||||
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await client.loadProtectedMockData();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:get-access-token" });
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:4319/api/mock/protected",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer abc123"
|
||||
}),
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws before fetch when the token is unavailable", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: false,
|
||||
type: "auth:error",
|
||||
error: "token missing"
|
||||
}));
|
||||
const fetchImpl = vi.fn();
|
||||
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await expect(client.loadProtectedMockData()).rejects.toThrow(/token/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws an authorization error on 401", async () => {
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ ok: false, error: "unauthorized" })
|
||||
})),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}))
|
||||
});
|
||||
|
||||
await expect(client.loadProtectedMockData()).rejects.toThrow(/unauthorized/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user