SurfSense Playwright E2E 实战:用测试数据工厂、Faker 与数据驱动模式构建可复用的测试数据体系
2026/9/14 19:07:33 网站建设 项目流程

SurfSense Playwright E2E 实战:用测试数据工厂、Faker 与数据驱动模式构建可复用的测试数据体系

【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense

在端到端(E2E)测试中,"如何构造并管理测试数据"往往决定了测试套件的稳定性与可维护性。本文以 SurfSense 仓库中沉淀的 Playwright 测试数据技能文档 test-data.md 为主体,完整讲解工厂模式(Factory Pattern)、Faker 集成、数据驱动测试、测试数据 Fixtures 以及数据库 Seeding 五类核心手段,并结合 SurfSense 前端 E2E 套件中真实存在的 fixtures、canary 令牌与 workspace 清理机制,说明这些模式在 Next.js + FastAPI 全栈项目里如何落地。读完本文,你能够独立设计一套"每个测试都有新鲜数据、失败可复现、资源可清理"的测试数据体系。

需要先明确本文档在 SurfSense 的 Playwright 技能体系中的定位:它专注可复用的测试数据构建器(factories、Faker、data generators),与另外两个相邻主题分工明确——

  • Per-test 数据库 fixtures(测试隔离、事务回滚):参见 fixtures-hooks.md 的 Database Fixtures 章节;
  • 一次性数据库初始化(migrations、snapshots):参见 global-setup.md 的 Database Patterns 章节。

也就是说,本文解决的是"数据从哪来、怎么长得可预测",而 fixture 生命周期与全局初始化由上述两篇配套文档覆盖。

一、Factory Pattern:用工厂函数替代散落的硬编码数据

1.1 基本工厂

最直接的工厂是一个带overrides参数的构造函数:默认值集中在一处,调用方只覆盖需要变化的字段。文档给出的factories/user.factory.ts示例完整如下:

// factories/user.factory.ts interface User { id: string; email: string; name: string; role: "admin" | "user" | "guest"; createdAt: Date; } let userIdCounter = 0; export function createUser(overrides: Partial<User> = {}): User { userIdCounter++; return { id: `user-${userIdCounter}`, email: `user${userIdCounter}@test.com`, name: `Test User ${userIdCounter}`, role: "user", createdAt: new Date(), ...overrides, }; } // Usage const user = createUser(); const admin = createUser({ role: "admin", name: "Admin User" });

两个设计细节值得注意:

  • 模块级计数器(userIdCounter)保证同进程内 ID 与 email 唯一。这在"一次运行内创建多个实体"的场景下天然避免主键/唯一约束冲突,但代价是数据不是"每测试一次全新重置"的——若跨测试有持久化,需要配合清理机制(见后文 Seeding 章节的cleanupUsers模式)。
  • ...overrides放在对象字面量最后,确保调用方覆盖优先级最高,这是工厂模式的约定:默认值 → 派生关系 → 显式覆盖。

1.2 带 Traits 的工厂

当某个实体存在常见的"状态变体"(缺货、促销、高价……)时,逐个手写 overrides 会变得冗长。Traits 把常见变体注册为命名片段,调用时按名追加:

// factories/product.factory.ts interface Product { id: string; name: string; price: number; stock: number; category: string; featured: boolean; } type ProductTrait = "outOfStock" | "featured" | "expensive" | "sale"; const traits: Record<ProductTrait, Partial<Product>> = { outOfStock: { stock: 0 }, featured: { featured: true }, expensive: { price: 999.99 }, sale: { price: 9.99 }, }; let productIdCounter = 0; export function createProduct( overrides: Partial<Product> = {}, ...traitNames: ProductTrait[] ): Product { productIdCounter++; const appliedTraits = traitNames.reduce( (acc, trait) => ({ ...acc, ...traits[trait] }), {}, ); return { id: `prod-${productIdCounter}`, name: `Product ${productIdCounter}`, price: 29.99, stock: 100, category: "General", featured: false, ...appliedTraits, ...overrides, }; } // Usage const product = createProduct(); const featuredProduct = createProduct({}, "featured"); const saleItem = createProduct({ name: "Sale Item" }, "sale", "featured"); const soldOut = createProduct({}, "outOfStock");

Trait 合并使用reduce按传入顺序依次展开,后一个 trait 覆盖前一个的相同字段(例如同时传"sale""expensive"时,最终价格由顺序决定);而overrides永远压过所有 traits。这个优先级链默认值 → traits → overrides是工厂可扩展性的关键:新增一个 trait 只需往traits表里加一行,所有既有调用不受影响。

1.3 带关系的工厂

真实数据几乎总是成体系的:订单引用用户,订单行引用商品。关系型工厂通过复用其他工厂来保证关系闭包完整,同时保留对每个关联对象的可覆盖性:

// factories/order.factory.ts import { createUser, User } from "./user.factory"; import { createProduct, Product } from "./product.factory"; interface OrderItem { product: Product; quantity: number; } interface Order { id: string; user: User; items: OrderItem[]; total: number; status: "pending" | "paid" | "shipped" | "delivered"; } let orderIdCounter = 0; export function createOrder(overrides: Partial<Order> = {}): Order { orderIdCounter++; const user = overrides.user ?? createUser(); const items = overrides.items ?? [{ product: createProduct(), quantity: 1 }]; const total = items.reduce( (sum, item) => sum + item.product.price * item.quantity, 0, ); return { id: `order-${orderIdCounter}`, user, items, total, status: "pending", ...overrides, }; } // Usage const order = createOrder(); const bigOrder = createOrder({ items: [ { product: createProduct({ price: 100 }), quantity: 5 }, { product: createProduct({ price: 50 }), quantity: 2 }, ], });

这里overrides.user ?? createUser()的写法体现了"关系工厂"的惯用法:先尊重调用方显式提供的关联对象,缺省时才用子工厂造一个;而total这样的派生字段在工厂内部根据items计算,避免调用方手算出错。

1.4 结合 SurfSense 仓库的实践对照

SurfSense 的 Web E2E 套件(playwright.config.ts 指定testDir: "./tests")并没有在浏览器侧维护一套纯内存工厂,而是把"数据创建"下沉为API 调用的 fixture——tests/fixtures/index.ts 中的注释明确说明了继承链:所有 connector fixture 都从workspaceFixtures派生,需要聊天线程的场景再extend一层chatThreadFixtures。这种"工厂即 API helper + fixture 组合"的思路与本文档的工厂模式同构,只是产物从内存对象变成了数据库真实行。

一个与"计数器唯一化"直接对应的真实实现是 tests/helpers/canary.ts 中的uniqueWorkspaceName

/** Generate a unique-per-run workspace name. Keeps parallel tests isolated. */ export function uniqueWorkspaceName(prefix = "e2e"): string { return `${prefix}-${randomUUID().slice(0, 8)}`; }

它用randomUUID()前 8 位生成每运行唯一的工作区名,源码注释点明了目的:让并行测试相互隔离。这与文档 Factory Pattern 中计数器方案的意图一致(唯一标识),但选择了"每运行随机"而非"进程内自增",更适配 Playwright 多 worker 并行的场景。

二、Faker Integration:用随机真实感数据,且保持可复现

2.1 安装与基本用法

Faker 解决"计数器数据缺乏真实感"的问题(如user1@test.com这样的数据无法暴露表单校验、渲染截断等问题):

npm install -D @faker-js/faker
// factories/faker-user.factory.ts import { faker } from "@faker-js/faker"; interface User { id: string; email: string; name: string; avatar: string; address: { street: string; city: string; country: string; zipCode: string; }; } export function createFakeUser(overrides: Partial<User> = {}): User { return { id: faker.string.uuid(), email: faker.internet.email(), name: faker.person.fullName(), avatar: faker.image.avatar(), address: { street: faker.location.streetAddress(), city: faker.location.city(), country: faker.location.country(), zipCode: faker.location.zipCode(), }, ...overrides, }; }

可以看到 Faker 工厂与第一节的手写工厂完全兼容:overrides机制原封不动,只是默认值来源换成了faker.*生成器。

2.2 Seeded Faker:可复现的随机数据

随机数据的头号风险是失败不可复现——同一失败重跑一次数据就变了。Faker 通过全局种子解决:

import { faker } from "@faker-js/faker"; // Set seed for reproducible data faker.seed(12345); export function createDeterministicUser(): User { return { id: faker.string.uuid(), email: faker.internet.email(), name: faker.person.fullName(), // Same seed = same data every time }; } // Or seed per test test("user profile", async ({ page }) => { faker.seed(42); // Reset seed for this test const user = createFakeUser(); // user will always have the same data });

文档给出了两个粒度的播种方式:模块级faker.seed(12345)(整条测试序列可复现)和测试级faker.seed(42)(每个测试从相同起点生成数据,既保留随机外观又保证单测内确定性)。

2.3 把 Faker 做成 Playwright Fixture

更工程化的做法是把"播种"封装进 fixture,让测试代码完全不感知种子细节:

// fixtures/faker.fixture.ts import { test as base } from "@playwright/test"; import { faker } from "@faker-js/faker"; type FakerFixtures = { fake: typeof faker; }; export const test = base.extend<FakerFixtures>({ fake: async ({}, use, testInfo) => { // Seed based on test name for reproducibility faker.seed(testInfo.title.length); await use(faker); }, }); // Usage test("create user with fake data", async ({ page, fake }) => { await page.goto("/signup"); await page.getByLabel("Name").fill(fake.person.fullName()); await page.getByLabel("Email").fill(fake.internet.email()); await page.getByLabel("Password").fill(fake.internet.password()); await page.getByRole("button", { name: "Sign Up" }).click(); });

这个 fixture 利用testInfo.title.length作为种子派生值,让"不同测试"得到不同数据、而"同一测试重跑"得到相同数据。

2.4 SurfSense 的选择:确定性 Canary 令牌而非 Faker

有意思的是,SurfSense 的实际 E2E 套件刻意没有使用随机数据。从 tests/helpers/canary.ts 的源码注释看,其设计动机是:

/** * Canary tokens & deterministic test data. * * Embedded by the backend Composio fake into fake Drive file contents * (see surfsense_backend/tests/e2e/fakes/fixtures/drive_files.json). * Specs assert these strings appear in the resulting Document rows to * prove the indexing pipeline ran end-to-end. * * Each token is a stable string keyed by file id so multi-test runs * remain deterministic and the resulting Document.content is greppable * in failure traces. */ export const CANARY_TOKENS = { driveCanaryFile: "SURFSENSE_E2E_CANARY_TOKEN_DRIVE_001", drivePdfCanary: "SURFSENSE_E2E_CANARY_TOKEN_DRIVE_PDF_001", ... } as const;

这与文档"Seeded Faker"一节的精神完全一致,甚至更彻底:SurfSense 的断言链路是跨进程的(connector → Celery → indexing → DB),需要证明"这条特定假文件的内容最终出现在 Document 行里",因此使用稳定的、按文件 id 索引的固定令牌(如SURFSENSE_E2E_CANARY_TOKEN_DRIVE_001),保证多次运行结果确定、且失败时令牌字符串可以直接在失败产物中 grep 定位。同文件中还有FAKE_DRIVE_FILESFAKE_GMAIL_MESSAGESFAKE_LINEAR_ISSUES等一批与后端 fake(如drive_files.json)严格对齐的假数据清单,形成"前后端共享同一份确定性测试数据集"的契约。可以推断:对于需要端到端内容穿透验证的套件,"可 grep 的稳定 canary"比 Faker 的随机真实感数据更具调试价值——这正是文档反模式表里"Random data without seed → 不可复现失败"在真实项目中的解法。

三、Data-Driven Testing:让数据定义用例

3.1 场景数组驱动用例

数据驱动测试的核心思想是把"用例矩阵"与"执行逻辑"分离。文档第一个例子是登录场景数组:

const loginScenarios = [ { email: "user@example.com", password: "pass123", expected: "Dashboard" }, { email: "admin@example.com", password: "admin123", expected: "Admin Panel" }, { email: "invalid@example.com", password: "wrong", expected: "Invalid credentials", }, ]; for (const { email, password, expected } of loginScenarios) { test(`login with ${email}`, async ({ page }) => { await page.goto("/login"); await page.getByLabel("Email").fill(email); await page.getByLabel("Password").fill(password); await page.getByRole("button", { name: "Sign In" }).click(); await expect(page.getByText(expected)).toBeVisible(); }); }

for循环 + 模板字符串标题让 Playwright 为每个数据行注册独立测试,失败时可精确定位到哪一行数据出了问题,报告里也能看到全部行。

3.2 参数化测试:把场景数据独立成模块

当场景数据变多或需要被多个 spec 共享时,抽到独立数据模块(data/checkout-scenarios.ts):

// data/checkout-scenarios.ts export const checkoutScenarios = [ { name: "standard shipping", shipping: "standard", expectedDays: "5-7 business days", expectedCost: "$5.99", }, { name: "express shipping", shipping: "express", expectedDays: "2-3 business days", expectedCost: "$14.99", }, { name: "overnight shipping", shipping: "overnight", expectedDays: "Next business day", expectedCost: "$29.99", }, ];
import { checkoutScenarios } from "./data/checkout-scenarios"; test.describe("shipping options", () => { for (const scenario of checkoutScenarios) { test(`checkout with ${scenario.name}`, async ({ page }) => { await page.goto("/checkout"); await page.getByLabel(scenario.shipping, { exact: false }).check(); await expect(page.getByText(scenario.expectedDays)).toBeVisible(); await expect(page.getByText(scenario.expectedCost)).toBeVisible(); }); } });

3.3 CSV/JSON 数据源

对非开发者(如 QA 或产品)维护的用例矩阵,最友好的形式是外部数据文件:

import fs from "fs"; interface TestCase { input: string; expected: string; } // Load test data from JSON const testCases: TestCase[] = JSON.parse( fs.readFileSync("./data/search-tests.json", "utf-8"), ); test.describe("search functionality", () => { for (const { input, expected } of testCases) { test(`search for "${input}"`, async ({ page }) => { await page.goto("/search"); await page.getByLabel("Search").fill(input); await page.getByLabel("Search").press("Enter"); await expect(page.getByText(expected)).toBeVisible(); }); } });

注意这里在describe作用域内同步读文件(模块加载期即完成解析),而不是在测试体内读——这保证数据文件缺失/格式错误时错误立即暴露在套件收集阶段,而不是某个用例执行中途。

SurfSense 对照:SurfSense 用目录结构实现了同一种"数据维度分离"。tests/README.md 规定"每个 connector 对应一条最小的浏览器旅程 spec",如 tests/connectors/composio/drive/journey.spec.ts;"数据"(fake 文件清单、canary 令牌)集中在 helpers/canary.ts 与后端fakes/fixtures/*.json,"逻辑"(connect → select scope → index → assert canary)集中在 journey spec。这与参数化测试"数据模块 + 单一执行循环"的分离是同一种工程思想在不同维度上的体现。

四、Test Data Fixtures:把工厂接进 Playwright 的依赖注入

4.1 Fixture with Factory

Playwright fixture 提供了比beforeEach更精细的依赖图。文档的示例把第一节定义的工厂挂到 fixture 上:

// fixtures/data.fixture.ts import { test as base } from "@playwright/test"; import { createUser, User } from "../factories/user.factory"; import { createProduct, Product } from "../factories/product.factory"; type DataFixtures = { testUser: User; testProducts: Product[]; }; export const test = base.extend<DataFixtures>({ testUser: async ({}, use) => { const user = createUser({ name: "E2E Test User" }); await use(user); }, testProducts: async ({}, use) => { const products = [ createProduct({ name: "Test Product 1" }), createProduct({ name: "Test Product 2" }), createProduct({ name: "Test Product 3" }), ]; await use(products); }, }); // Usage test("add product to cart", async ({ page, testUser, testProducts }) => { // Mock API with test data await page.route("**/api/user", (route) => route.fulfill({ json: testUser })); await page.route("**/api/products", (route) => route.fulfill({ json: testProducts }), ); await page.goto("/products"); await expect(page.getByText(testProducts[0].name)).toBeVisible(); });

这里还有两个值得注意的点:

  • use()之前的代码是 setup,之后的代码是 teardown。fixture 因此可以精确表达"数据何时创建、何时回收",这是beforeEach/afterEach难以做到的。
  • 工厂产物既可以直接喂给page.route()做 API mock(本例),也可以作为真实请求的 payload(下一节)。同一份工厂数据在两条路径间复用,避免了 mock 数据与真实数据各写一套。

4.2 SurfSense 的 fixture 继承链

SurfSense 把这种"fixture 承载测试数据"的模式用到了极致,tests/fixtures/index.ts 的文件头注释画出了完整继承链(base → workspaceFixtures → 各 connector fixtures → 可选 chatThreadFixtures),并导出 15+ 个按场景命名的test变体(composioDriveTestnativeGmailWithChatTest……)。其中 tests/fixtures/chat-thread.fixture.ts 展示了"数据即 API 调用"的 fixture 写法:

chatThread: async ({ request, apiToken, workspace }, use) => { const response = await request.post(`${BACKEND_URL}/api/v1/threads`, { headers: authHeaders(apiToken), data: { title: "e2e-drive-journey", workspace_id: workspace.id, visibility: "PRIVATE", }, }); if (!response.ok()) { throw new Error(`create chat thread failed (${response.status()}): ${await response.text()}`); } await use((await response.json()) as ChatThreadRow); },

对照文档的 Fixture with Factory 模式,可以看到一致的三要素:类型化的 fixture 名ChatThreadFixtures.chatThread)、创建逻辑集中在 fixture 内(而非散落在各 spec)、失败快速显形(非 2xx 直接抛错带响应体)。而"组合性"则体现在 index.ts 中一行composioDriveFixtures.extend<ChatThreadFixtures>(chatThreadFixtures)就完成了新数据维度的叠加。

五、Database Seeding:数据要进库,也要能退场

5.1 API-Based Seeding(推荐路径)

通过应用自己的 API 造数据,天然保证数据经过真实校验逻辑。文档示例的精髓在于把"创建"与"清理"配成一对 fixture

// fixtures/seed.fixture.ts import { test as base, APIRequestContext } from "@playwright/test"; import { createUser } from "../factories/user.factory"; type SeedFixtures = { seedUser: (overrides?: Partial<User>) => Promise<User>; cleanupUsers: string[]; }; export const test = base.extend<SeedFixtures>({ cleanupUsers: [], seedUser: async ({ request, cleanupUsers }, use) => { await use(async (overrides = {}) => { const userData = createUser(overrides); const response = await request.post("/api/test/users", { data: userData, }); const user = await response.json(); cleanupUsers.push(user.id); return user; }); }, // Cleanup after test cleanupUsers: async ({ request }, use) => { const userIds: string[] = []; await use(userIds); // Delete all created users for (const id of userIds) { await request.delete(`/api/test/users/${id}`); } }, }); // Usage test("user profile page", async ({ page, seedUser }) => { const user = await seedUser({ name: "John Doe" }); await page.goto(`/users/${user.id}`); await expect(page.getByText("John Doe")).toBeVisible(); });

cleanupUsers数组作为 fixture 被seedUser依赖注入,所有创建过的 id 累积其中,teardown 阶段统一删除——"谁造的数据谁负责回收"这一契约由依赖图强制保证,而不是靠开发者记忆。

SurfSense 的对应实现几乎是该模式的逐行放大:tests/fixtures/workspace.fixture.ts 中,apiToken使用{ scope: "worker" }让每个 worker 只登录一次("logins are cheap, but caching is cheaper"),而workspacefixture 则严格遵循"创建 → use → finally 删除"的闭环:

workspace: async ({ request, apiToken }, use) => { const space = await createWorkspace( request, apiToken, uniqueWorkspaceName("composio-drive-e2e") ); try { await use(space); } finally { await deleteWorkspace(request, apiToken, space.id); } },

配合 auth.setup.ts 的一次性认证(获取 bearer token 后写入playwright/.auth/user.json会话状态,playwright.config.ts的 chromium project 通过storageState复用),整个套件实现了文档反模式表所要求的目标:每测试新鲜数据 + 测试间零串扰。至于为什么选择 API 造数据而不是直连数据库,tests/README.md 的 "Why API-driven?" 一节给出了明确理由:确定性(不等待 UI 动画/水合/编译)、走与 UI 相同的后端代码路径、以及把昂贵的 E2E 断言留给"只有 E2E 才能证明"的跨进程接缝(connector → Celery → indexing → DB)。

5.2 Transaction Rollback Seeding(直连数据库路径)

当需要绕过应用层直接写库(如造 API 无法创建的数据、或验证 DB 级不变式),事务回滚是最干净的隔离手段——每个测试在独立事务里种数据,测试结束ROLLBACK,数据库回到原点:

// fixtures/db.fixture.ts export const test = base.extend<{}, { db: DbTransaction }>({ db: [ async ({}, use) => { const client = await pool.connect(); await client.query("BEGIN"); await use({ query: (sql: string, params?: any[]) => client.query(sql, params), seed: async (table: string, data: object) => { const keys = Object.keys(data); const values = Object.values(data); const placeholders = keys.map((_, i) => `$${i + 1}`); const result = await client.query( `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`, values, ); return result.rows[0]; }, }); await client.query("ROLLBACK"); client.release(); }, { scope: "test" }, ], });

实现要点:

  • { scope: "test" }保证每个测试独立事务、独立连接,互不泄漏;
  • BEGIN/ROLLBACK夹住整个use,即使测试失败,use抛出后依然会执行到ROLLBACK(fixture teardown 语义);
  • seed助手用$1, $2, ...占位符参数化 INSERT 并RETURNING *,返回插入后的完整行(含数据库生成的列),测试可直接用返回值做断言。

需要注意适用前提:该模式要求测试对数据库有直连权限、且被测逻辑不会开启自己的独立事务/连接(否则回滚隔离失效)。这也是文档开头把"事务回滚"归入 fixtures-hooks.md 的 Database Fixtures 专题、而本文只给出种子接口的原因——两条路径应按项目实际权限与隔离需求二选一。

六、Anti-Patterns:四类必须避免的测试数据反模式

文档最后给出的反模式对照表是全文的收束,值得逐条落实:

反模式问题解法
硬编码测试数据(Hardcoded test data)脆弱、重复使用工厂
未播种的随机数据(Random data without seed)失败不可复现每个测试播种 faker
共享可变测试数据(Shared mutable test data)测试相互干扰每测试创建新鲜数据
到处手工造数据(Manual data creation everywhere)重复、维护负担集中到工厂

前文的 SurfSense 实践可以逐一映射回这张表:uniqueWorkspaceName用随机 UUID 前缀避免共享数据串扰(第 3 行);canary 令牌全部是稳定可 grep 的字符串而非未播种随机值(第 2 行);workspacefixture 的 create/finally-delete 闭环让每个测试拿到全新工作区(第 1、3 行);数据构造集中在helpers/api/*与 fixtures,spec 里不出现裸 SQL/裸 JSON(第 4 行)。

七、延伸阅读与参考路径

  • Fixtures 模式:fixtures-hooks.md——fixture 生命周期、hook 与数据库 fixture 细节;
  • 全局初始化:global-setup.md——一次性 migrations / snapshot 类 Database Patterns;
  • API 测试与 mocking:test-suite-structure.md;
  • SurfSense 实际套件:tests/README.md(三层防真实外呼的确定性 harness)、playwright.config.ts(默认测试账号e2e-test@surfsense.net等环境约定、storageStatewebServer配置)、tests/helpers/canary.ts(确定性 canary 令牌全集)、tests/fixtures/workspace.fixture.ts(worker 级 token 缓存 + 测试级工作区创建/清理)、tests/fixtures/index.ts(15+ 个 typed test 变体的组合方式);
  • 后端侧 E2E 入口surfsense_backend/tests/e2e/run_backend.pyrun_celery.py(按 tests/README.md 所述,在导入应用前劫持sys.modules注入严格 fake,配合哨兵 API key 与代理拒绝,保证任何泄漏的外呼在网络前即失败)。

小结:测试数据的工程化可以浓缩为一句话——用工厂集中默认值、用 traits/overrides 表达变体、用种子或稳定令牌保证可复现、用 fixture 的 setup/teardown 语义保证隔离与回收。工厂模式负责"数据怎么来",Faker(或确定性 canary)负责"数据长什么样",数据驱动负责"哪些数据值得测",而 API Seeding / 事务回滚负责"数据进库与退场"。把这四层各自落实,E2E 套件就具备了"失败可定位、重跑可复现、并行不串扰"的确定性基础。

【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询