上传助手
+本地 CSV 批量填写
+选择数据文件
+ 仅在本地解析 +导入预览
+ +| 文件名 | +标题 | +关键词 | +
|---|
扩展不会自动提交,请在处理后人工检查。
+diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0cecf8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.jpg binary +*.png binary +*.zip binary diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..96514af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,41 @@ +name: Bug 报告 +description: 报告 CSV、页面兼容或任务控制问题 +title: "[Bug] " +labels: [bug] +body: + - type: markdown + attributes: + value: | + 请先删除账号、Cookie、合同、未公开原图和其他敏感信息。不要上传完整真实 CSV。 + - type: input + id: version + attributes: + label: 扩展版本 + placeholder: 例如 1.1.0 + validations: + required: true + - type: input + id: browser + attributes: + label: 浏览器与版本 + placeholder: 例如 Chrome 140 + validations: + required: true + - type: textarea + id: steps + attributes: + label: 复现步骤 + description: 请使用脱敏数据描述从哪里开始、看到什么结果。 + validations: + required: true + - type: textarea + id: expected + attributes: + label: 预期结果 + validations: + required: true + - type: textarea + id: logs + attributes: + label: 脱敏后的错误或页面信息 + render: text diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..27f7281 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,25 @@ +name: 功能建议 +description: 建议一个符合本地、可审计边界的改进 +title: "[Feature] " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: 需要解决的问题 + description: 请描述实际工作流和重复成本,不要只描述按钮或界面。 + validations: + required: true + - type: textarea + id: outcome + attributes: + label: 期望结果 + validations: + required: true + - type: checkboxes + id: boundaries + attributes: + label: 项目边界 + options: + - label: 该建议不要求抓取平台数据、绕过访问控制或自动最终提交 + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0ff014e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## 改动 + +请说明修改了什么,以及用户能看到的影响。 + +## 原因 + +请说明根因、兼容性依据或实际工作流问题。 + +## 验证 + +- [ ] `npm run check` +- [ ] 没有新增未说明的 Chrome 权限 +- [ ] 没有新增遥测或隐藏网络请求 +- [ ] 测试数据已经脱敏 +- [ ] 页面结构变化附带了脱敏证据或模拟测试 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..36cb2b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Verify source and build package + run: npm run check + - name: Upload extension package + uses: actions/upload-artifact@v4 + with: + name: VCGVisionHelper + path: | + dist/*.zip + dist/*.sha256 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..793266d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +*.log +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d46d735 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# 更新记录 + +## 1.1.0 - 2026-08-03 + +- 将真实扩展源码纳入 Git 仓库,移除不可维护的混淆发布方式 +- 恢复并测试 CSV 解析,支持 UTF-8、BOM、GB18030/GBK +- 增加完整表头、逐行字段、重复文件名和关键词数量校验 +- 增加导入预览、清晰错误状态和无障碍界面 +- 使用单任务状态机重写暂停、继续和立即停止 +- 增加 DOM 等待、可见元素筛选、写入验证和逐条错误隔离 +- 增加处理报告导出,并防止表格公式注入 +- 增加隐私、安全、贡献和使用文档 +- 增加零依赖测试、静态检查、确定性 ZIP 构建和 GitHub Actions + +## 1.0.1 - 2026-04-16 + +- 首次公开 Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..919b66c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# 参与贡献 + +欢迎提交问题、兼容性修复、测试和文档改进。 + +## 报告问题 + +请尽量提供浏览器版本、扩展版本、失败步骤、脱敏后的页面结构或截图、最小化 CSV 示例和控制台错误文本。 + +不要提交账号、Cookie、合同、未公开原图、真实收款信息或其他敏感数据。 + +## 开发流程 + +1. Fork 仓库并创建分支。 +2. 修改代码和对应测试。 +3. 运行 `npm run check`。 +4. 提交 Pull Request,说明用户影响、测试结果和 VCG 页面兼容性。 + +## 设计约束 + +- 保持纯本地运行,不引入遥测或隐藏网络请求。 +- 不增加绕过登录、验证码、访问控制或平台限制的能力。 +- 不自动执行最终提交。 +- DOM 选择器变化需要附带脱敏证据或模拟页面测试。 +- 新权限必须在 Pull Request 中解释必要性。 diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..a4b1313 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,34 @@ +# 隐私说明 + +VCG 上传助手采用纯本地架构。 + +## 扩展处理的数据 + +- 用户主动选择的 CSV 文件 +- CSV 中的文件名、标题和关键词 +- 当前 VCG 页面上用于匹配图片和填写表单的 DOM 元素 +- 本次任务的成功、失败和跳过结果 + +## 数据如何使用 + +CSV 在扩展弹窗的内存中解析,校验通过的数据通过 Chrome 扩展消息发送到当前活动的 VCG 页面。任务报告仅在当前页面内存中保存,除非用户主动点击“导出处理报告”。 + +## 不会发生的行为 + +- 不上传 CSV、标题、关键词或页面内容 +- 不采集账号、密码、Cookie 或身份信息 +- 不使用分析、遥测、广告或指纹识别 +- 不访问第三方接口 +- 不自动点击最终提交 + +## 本地图片 + +项目中的微信和支付宝二维码是随扩展打包的静态图片。打开赞助区域不会产生网络请求。 + +## 数据保留 + +扩展不使用本地数据库或云端存储。关闭或刷新相关页面后,任务状态和内存数据会消失。用户主动导出的 CSV 报告由用户自行保管。 + +## 联系方式 + +隐私问题可发送至 `1207048080@qq.com`。 diff --git a/README.md b/README.md index 2ff55eb..c523905 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,91 @@ -# VCGVisionHelper -这个插件作者是一个刚刚经历人生毁灭打击的大学生,通过整理照片用过去和回忆疗伤,在过程中发现上传过程太过繁琐于是……欢迎使用创作助手!本工具将完全释放您的双手,自动实现关键词和标题的填充 !仅供学习交流使用!! +# VCG 上传助手 + +一个面向视觉中国供稿人的本地 Chrome 扩展。它读取 CSV 文件,按图片文件名匹配草稿,并辅助填写标题和关键词。 + +项目坚持三个原则:数据只在本地处理、用户保留最终确认权、代码保持可审计。 + +## 功能 + +- 支持 UTF-8、UTF-8 BOM、GB18030/GBK CSV +- 支持引号内逗号、换行和双引号 +- 导入前检查表头、空字段、重复文件名和关键词数量 +- 按 `title="文件名"` 精确匹配图片,不拼接不安全的 CSS 选择器 +- 兼容 React/Vue 受控输入框 +- 提供暂停、继续、立即停止、进度和逐条结果 +- 处理完成后可导出 CSV 报告 +- 不读取账号密码,不上传 CSV,不请求任何外部接口 +- 不自动点击最终提交,结果由用户检查后提交 + +## 安装 + +### 从 Release 安装 + +1. 打开仓库的 [Releases](https://github.com/KanadeK/VCGVisionHelper/releases)。 +2. 下载 `VCGVisionHelper-v1.1.0.zip`,不要下载 GitHub 自动生成的 Source code 压缩包。 +3. 解压到固定目录。 +4. 打开 `chrome://extensions/`。 +5. 开启开发者模式,点击“加载已解压的扩展程序”,选择解压目录。 + +Chrome 和基于 Chromium 的新版 Edge 均可使用。 + +## CSV 格式 + +表头必须包含以下三列: + +```csv +file_name,title,keywords +IMG_0001.jpg,清晨湖面上的薄雾,"湖泊,薄雾,清晨,自然,风景" +``` + +- `file_name`:包含后缀,并与网页显示的文件名完全一致。 +- `title`:单行标题。 +- `keywords`:5-35 个关键词,使用英文逗号分隔。 + +示例文件见 [`docs/sample.csv`](docs/sample.csv)。 + +## 使用 + +1. 登录视觉中国供稿人后台,进入待编辑草稿页面并刷新。 +2. 点击扩展图标,选择 CSV 文件。 +3. 检查导入预览与校验结果。 +4. 点击“开始填写”。 +5. 通过网页右上角控制台暂停、继续或停止。 +6. 完成后检查标题、关键词与处理报告,再由你手动提交。 + +完整说明见 [`docs/USAGE.md`](docs/USAGE.md)。 + +## 隐私与安全 + +扩展不包含统计、遥测、广告、卡密验证或网络请求。CSV 只在当前浏览器内存中解析,并发送给当前活动的 VCG 页面。详见 [`PRIVACY.md`](PRIVACY.md)。 + +本工具用于减少重复录入,不用于抓取平台数据、绕过访问控制或替代人工审核。使用前请确认你的供稿协议和平台规则允许相应辅助操作。 + +## 本地开发 + +需要 Node.js 20 或更高版本,不需要安装第三方依赖。 + +```bash +npm test +npm run lint +npm run build +npm run check +``` + +`npm run build` 会生成: + +- `dist/VCGVisionHelper-v1.1.0.zip` +- `dist/VCGVisionHelper-v1.1.0.zip.sha256` + +构建脚本使用固定 ZIP 元数据,同一源码会产生相同字节和 SHA-256。 + +## 参与贡献 + +问题报告和改进建议请阅读 [`CONTRIBUTING.md`](CONTRIBUTING.md)。安全问题请按 [`SECURITY.md`](SECURITY.md) 中的方式反馈。 + +## 赞助与反馈 + +扩展内保留微信和支付宝赞助二维码。反馈邮箱:`1207048080@qq.com`。 + +## 许可证 + +[MIT](LICENSE) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..0c58be8 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,19 @@ +# VCG 上传助手 v1.1.0 + +这是第一个源码、测试和安装包保持一致的正式开源版本。 + +## 主要改进 + +- 修复公开版缺失 CSV 解析函数导致无法启动的问题 +- 支持 UTF-8、UTF-8 BOM、GB18030/GBK CSV +- 导入前完整检查三列数据、重复文件名和 5-35 个关键词限制 +- 新增导入预览、暂停、继续、立即停止和 CSV 处理报告 +- 重写 DOM 匹配与等待逻辑,减少误填和固定延迟导致的失败 +- 保持纯本地、无遥测、无网络请求、不自动提交 +- 新增自动测试、CI、确定性 ZIP 和 SHA-256 校验文件 + +## 安装 + +下载 `VCGVisionHelper-v1.1.0.zip`,解压后通过 Chrome 的“加载已解压的扩展程序”安装。不要下载 GitHub 自动生成的 Source code 压缩包。 + +首次正式使用前,请用 2-3 张图片验证当前 VCG 页面结构,并在处理后人工检查再提交。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9359ad7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# 安全策略 + +## 支持版本 + +安全修复仅面向最新 Release。 + +## 报告安全问题 + +请不要公开披露包含账号数据或可利用细节的 Issue。发送邮件至 `1207048080@qq.com`,主题注明“VCG 上传助手安全问题”。 + +请包含扩展版本、浏览器版本、复现步骤和影响范围。不要发送真实密码、Cookie 或完整个人数据。 + +## 安全边界 + +扩展只在用户主动选择 CSV 并点击开始后运行。它不提供远程控制、账号接管、验证码绕过、平台数据抓取或最终提交功能。 diff --git a/assets/alipay.jpg b/assets/alipay.jpg new file mode 100644 index 0000000..2600f56 Binary files /dev/null and b/assets/alipay.jpg differ diff --git a/assets/wechat.jpg b/assets/wechat.jpg new file mode 100644 index 0000000..898e583 Binary files /dev/null and b/assets/wechat.jpg differ diff --git a/content.js b/content.js new file mode 100644 index 0000000..8f0f549 --- /dev/null +++ b/content.js @@ -0,0 +1,370 @@ +(function initializeVCGHelper() { + "use strict"; + + if (window.VCGVisionHelperLoaded) return; + window.VCGVisionHelperLoaded = true; + + const SELECTORS = { + titleInput: [ + 'input[placeholder*="一句话描述作品"]', + "input.right-form-title", + 'input[placeholder*="标题"]', + ], + keywordInput: [ + 'textarea[placeholder*="请添加5~35个关键词"]', + 'textarea[placeholder*="关键词"]', + ], + textModeCandidates: "button, [role='button'], span", + }; + + let activeTask = null; + + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request?.action !== "vcg:start") return false; + if (!Array.isArray(request.records) || request.records.length === 0) { + sendResponse({ ok: false, message: "没有可处理的数据" }); + return false; + } + if (activeTask && ["running", "paused", "stopping"].includes(activeTask.state)) { + sendResponse({ ok: false, message: "已有任务正在运行,请先完成或停止" }); + return false; + } + + activeTask = createTask(request.records); + createConsole(activeTask); + runTask(activeTask).catch((error) => { + if (error?.name !== "AbortError") console.error("VCG 上传助手任务异常", error); + }); + sendResponse({ ok: true, taskId: activeTask.id }); + return false; + }); + + function createTask(records) { + return { + id: typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}`, + state: "running", + records, + index: 0, + results: [], + controller: new AbortController(), + pauseWaiters: [], + }; + } + + function isVisible(element) { + if (!(element instanceof HTMLElement)) return false; + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + } + + function findVisible(selectors) { + for (const selector of selectors) { + const match = Array.from(document.querySelectorAll(selector)).find(isVisible); + if (match) return match; + } + return null; + } + + function findFileElement(fileName) { + return Array.from(document.querySelectorAll("[title]")).find( + (element) => element.getAttribute("title") === fileName && isVisible(element), + ); + } + + function findTextModeButton() { + const candidate = Array.from(document.querySelectorAll(SELECTORS.textModeCandidates)).find( + (element) => isVisible(element) && element.textContent?.trim() === "文本模式", + ); + return candidate?.closest("button, [role='button']") || candidate || null; + } + + function abortError() { + return new DOMException("任务已停止", "AbortError"); + } + + function throwIfAborted(task) { + if (task.controller.signal.aborted) throw abortError(); + } + + function delay(task, min = 450, max = 850) { + throwIfAborted(task); + const duration = Math.round(Math.random() * (max - min) + min); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + task.controller.signal.removeEventListener("abort", onAbort); + resolve(); + }, duration); + function onAbort() { + clearTimeout(timer); + reject(abortError()); + } + task.controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + async function checkpoint(task) { + throwIfAborted(task); + while (task.state === "paused") { + await new Promise((resolve) => task.pauseWaiters.push(resolve)); + throwIfAborted(task); + } + } + + function releasePause(task) { + task.pauseWaiters.splice(0).forEach((resolve) => resolve()); + } + + async function waitForVisible(task, selectors, timeout = 8000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + await checkpoint(task); + const element = findVisible(selectors); + if (element) return element; + await delay(task, 100, 180); + } + return null; + } + + function setNativeValue(element, value) { + element.focus(); + const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (setter) setter.call(element, value); + else element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); + element.dispatchEvent(new Event("change", { bubbles: true })); + if (element.value !== value) throw new Error("网页未接受输入值"); + } + + function pressEnter(element) { + const options = { + key: "Enter", + code: "Enter", + keyCode: 13, + which: 13, + charCode: 13, + bubbles: true, + cancelable: true, + }; + element.dispatchEvent(new KeyboardEvent("keydown", options)); + element.dispatchEvent(new KeyboardEvent("keypress", options)); + element.dispatchEvent(new KeyboardEvent("keyup", options)); + } + + async function processRecord(task, record) { + const fileElement = findFileElement(record.file_name); + if (!fileElement) return { status: "skipped", reason: "当前页面未找到同名图片" }; + + const card = fileElement.closest(".card, .ant-card, [data-testid*='card']") || fileElement.parentElement; + if (!card) return { status: "failed", reason: "无法定位图片卡片" }; + card.click(); + await delay(task, 900, 1300); + await checkpoint(task); + + const titleInput = await waitForVisible(task, SELECTORS.titleInput); + if (!titleInput) return { status: "failed", reason: "未找到标题输入框" }; + setNativeValue(titleInput, record.title); + await delay(task); + await checkpoint(task); + + let keywordInput = findVisible(SELECTORS.keywordInput); + if (!keywordInput) { + const textModeButton = findTextModeButton(); + if (!textModeButton) return { status: "failed", reason: "未找到文本模式按钮" }; + textModeButton.click(); + await delay(task, 400, 700); + keywordInput = await waitForVisible(task, SELECTORS.keywordInput); + } + + if (!keywordInput) return { status: "failed", reason: "未找到关键词输入框" }; + setNativeValue(keywordInput, record.keywords); + await delay(task, 350, 600); + pressEnter(keywordInput); + await delay(task, 800, 1200); + keywordInput.blur(); + return { status: "success", reason: "已填写并触发关键词分词" }; + } + + async function runTask(task) { + updateConsole(task, "准备处理", "正在检查当前页面"); + try { + for (let index = 0; index < task.records.length; index += 1) { + task.index = index; + await checkpoint(task); + const record = task.records[index]; + updateConsole(task, `正在处理 ${record.file_name}`, `${index + 1} / ${task.records.length}`); + try { + const result = await processRecord(task, record); + task.results.push({ file_name: record.file_name, ...result }); + } catch (error) { + if (error?.name === "AbortError") throw error; + console.error(`处理 ${record.file_name} 失败`, error); + task.results.push({ + file_name: record.file_name, + status: "failed", + reason: error instanceof Error ? error.message : "未知错误", + }); + } + updateConsole(task, resultSummary(task), `${index + 1} / ${task.records.length}`); + if (index < task.records.length - 1) await delay(task, 700, 1100); + } + task.state = "done"; + updateConsole(task, "处理完成", resultSummary(task)); + } catch (error) { + if (error?.name !== "AbortError") throw error; + task.state = "stopped"; + updateConsole(task, "任务已停止", resultSummary(task)); + } finally { + finishConsole(task); + } + } + + function resultSummary(task) { + const success = task.results.filter((item) => item.status === "success").length; + const failed = task.results.filter((item) => item.status === "failed").length; + const skipped = task.results.filter((item) => item.status === "skipped").length; + return `成功 ${success},失败 ${failed},跳过 ${skipped}`; + } + + function ensureStyles() { + if (document.getElementById("vcgh-style")) return; + const style = document.createElement("style"); + style.id = "vcgh-style"; + style.textContent = ` + #vcgh-console { position: fixed; top: 20px; right: 20px; width: 300px; z-index: 2147483647; color: #edf2f8; background: #172033; border: 1px solid #3a4760; border-radius: 16px; box-shadow: 0 16px 48px rgba(15, 23, 42, .32); font-family: "Segoe UI Variable", "Segoe UI", "Microsoft YaHei UI", sans-serif; overflow: hidden; } + #vcgh-console * { box-sizing: border-box; } + .vcgh-head { display: flex; justify-content: space-between; align-items: center; padding: 15px 16px 10px; } + .vcgh-brand { font-size: 14px; font-weight: 750; } + .vcgh-close { display: none; width: 28px; height: 28px; padding: 0; border: 1px solid #526077; border-radius: 9px; color: #edf2f8; background: transparent; cursor: pointer; } + .vcgh-body { padding: 0 16px 16px; } + .vcgh-title { margin: 0; color: #edf2f8; font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; } + .vcgh-detail { margin: 6px 0 0; color: #b3bfd1; font-size: 12px; line-height: 1.45; } + .vcgh-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 14px; } + .vcgh-button { min-height: 36px; border: 1px solid #526077; border-radius: 11px; color: #edf2f8; background: #222d42; cursor: pointer; font-weight: 700; } + .vcgh-button:hover:not(:disabled) { border-color: #6f96ef; } + .vcgh-button:active:not(:disabled) { transform: translateY(1px); } + .vcgh-button:disabled { cursor: not-allowed; opacity: .5; } + .vcgh-primary { color: #101725; border-color: #6f96ef; background: #6f96ef; } + .vcgh-export { display: none; width: 100%; margin-top: 8px; } + .vcgh-note { margin: 12px 0 0; color: #93a3bb; font-size: 10px; line-height: 1.45; } + @media (prefers-reduced-motion: reduce) { #vcgh-console * { transition: none !important; } } + `; + document.documentElement.appendChild(style); + } + + function createConsole(task) { + ensureStyles(); + document.getElementById("vcgh-console")?.remove(); + const container = document.createElement("section"); + container.id = "vcgh-console"; + container.setAttribute("aria-label", "VCG 上传助手控制台"); + + const head = document.createElement("div"); + head.className = "vcgh-head"; + const brand = document.createElement("div"); + brand.className = "vcgh-brand"; + brand.textContent = "VCG 上传助手"; + const close = document.createElement("button"); + close.className = "vcgh-close"; + close.type = "button"; + close.textContent = "×"; + close.setAttribute("aria-label", "关闭控制台"); + close.addEventListener("click", () => container.remove()); + head.append(brand, close); + + const body = document.createElement("div"); + body.className = "vcgh-body"; + const title = document.createElement("p"); + title.className = "vcgh-title"; + title.setAttribute("aria-live", "polite"); + const detail = document.createElement("p"); + detail.className = "vcgh-detail"; + const actions = document.createElement("div"); + actions.className = "vcgh-actions"; + const pause = document.createElement("button"); + pause.type = "button"; + pause.className = "vcgh-button vcgh-primary"; + pause.textContent = "暂停"; + pause.addEventListener("click", () => togglePause(task)); + const stop = document.createElement("button"); + stop.type = "button"; + stop.className = "vcgh-button"; + stop.textContent = "停止"; + stop.addEventListener("click", () => stopTask(task)); + actions.append(pause, stop); + + const exportButton = document.createElement("button"); + exportButton.type = "button"; + exportButton.className = "vcgh-button vcgh-export"; + exportButton.textContent = "导出处理报告"; + exportButton.addEventListener("click", () => exportReport(task)); + const note = document.createElement("p"); + note.className = "vcgh-note"; + note.textContent = "扩展只填写字段,不会自动提交。"; + + body.append(title, detail, actions, exportButton, note); + container.append(head, body); + document.body.appendChild(container); + task.ui = { container, title, detail, pause, stop, exportButton, close }; + } + + function updateConsole(task, title, detail) { + if (!task.ui?.container.isConnected) return; + task.ui.title.textContent = title; + task.ui.detail.textContent = detail; + } + + function togglePause(task) { + if (task.state === "running") { + task.state = "paused"; + task.ui.pause.textContent = "继续"; + updateConsole(task, "任务已暂停", `${task.index + 1} / ${task.records.length}`); + } else if (task.state === "paused") { + task.state = "running"; + task.ui.pause.textContent = "暂停"; + releasePause(task); + updateConsole(task, "任务已继续", `${task.index + 1} / ${task.records.length}`); + } + } + + function stopTask(task) { + if (!["running", "paused"].includes(task.state)) return; + task.state = "stopping"; + task.ui.pause.disabled = true; + task.ui.stop.disabled = true; + updateConsole(task, "正在停止", resultSummary(task)); + releasePause(task); + task.controller.abort(); + } + + function finishConsole(task) { + if (!task.ui?.container.isConnected) return; + task.ui.pause.disabled = true; + task.ui.stop.disabled = true; + task.ui.close.style.display = "block"; + if (task.results.length > 0) task.ui.exportButton.style.display = "block"; + } + + function csvCell(value) { + let text = String(value ?? ""); + if (/^[=+\-@]/.test(text)) text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; + } + + function exportReport(task) { + const lines = [["file_name", "status", "reason"].map(csvCell).join(",")]; + task.results.forEach((result) => { + lines.push([result.file_name, result.status, result.reason].map(csvCell).join(",")); + }); + const blob = new Blob(["\uFEFF", lines.join("\r\n")], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `VCG处理报告-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } +})(); diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..5bdc644 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,49 @@ +# VCG 上传助手使用说明 + +## 安装 + +1. 从 GitHub Release 下载名称为 `VCGVisionHelper-v1.1.0.zip` 的附件。 +2. 解压到不会随意移动或删除的固定目录。 +3. 打开 Chrome 的 `chrome://extensions/` 页面。 +4. 开启开发者模式。 +5. 点击“加载已解压的扩展程序”,选择解压目录。 +6. 将“VCG 上传助手”固定到浏览器工具栏。 + +## 准备 CSV + +首行必须包含 `file_name`、`title`、`keywords`。关键词必须使用英文逗号分隔,数量为 5-35 个。CSV 可以使用 UTF-8、UTF-8 BOM、GB18030 或常见 GBK 编码。 + +建议先用 2-3 张图片测试,再处理更大的批次。 + +## 执行 + +1. 登录 VCG 供稿人后台并进入待编辑草稿页面。 +2. 刷新页面,确认所有待处理图片已经显示。 +3. 打开扩展并选择 CSV。 +4. 修正界面提示的所有错误。 +5. 点击“开始填写”。 +6. 使用网页右上角控制台暂停、继续或停止。 +7. 完成后导出报告,检查失败和跳过项。 +8. 人工检查页面中的标题与关键词,再手动提交。 + +## 常见问题 + +### 提示“请打开 VCG 草稿页面并刷新” + +确认当前活动标签页属于 `vcg.com`,然后刷新页面。安装扩展前已经打开的页面通常需要刷新一次。 + +### 找不到同名图片 + +`file_name` 必须包含后缀,并与网页元素的 `title` 属性完全一致。还要确认图片已经加载在当前页面中。 + +### 找不到输入框或文本模式按钮 + +VCG 可能更新了页面结构。先停止任务,保留处理报告,然后在 GitHub Issue 中提供脱敏截图、浏览器版本和失败步骤。不要公开账号信息、图片原稿或合同信息。 + +### 停止后仍有一项被填写 + +停止会立即取消插件自身的等待和后续步骤,但已经派发给网页的输入事件无法撤回。请人工检查当前图片。 + +## 数据说明 + +扩展不发送网络请求,也不持久化 CSV。刷新或关闭页面会结束当前任务。更多信息见项目根目录的 `PRIVACY.md`。 diff --git a/docs/sample.csv b/docs/sample.csv new file mode 100644 index 0000000..b946d11 --- /dev/null +++ b/docs/sample.csv @@ -0,0 +1,3 @@ +file_name,title,keywords +IMG_0001.jpg,清晨湖面上的薄雾,"湖泊,薄雾,清晨,自然,风景" +IMG_0002.jpg,城市街道上的雨夜倒影,"城市,街道,雨夜,倒影,交通,建筑" diff --git a/lib/csv.js b/lib/csv.js new file mode 100644 index 0000000..76275e9 --- /dev/null +++ b/lib/csv.js @@ -0,0 +1,182 @@ +(function attachVCGCSV(root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + root.VCGCSV = api; +})(typeof globalThis !== "undefined" ? globalThis : this, function createVCGCSV() { + "use strict"; + + const REQUIRED_HEADERS = ["file_name", "title", "keywords"]; + + function normalizeHeader(value) { + return String(value ?? "").replace(/^\uFEFF/, "").trim().toLowerCase(); + } + + function decodeCSVBuffer(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + return { text: new TextDecoder("utf-16le").decode(bytes).replace(/^\uFEFF/, ""), encoding: "UTF-16LE" }; + } + if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { + return { text: new TextDecoder("utf-16be").decode(bytes).replace(/^\uFEFF/, ""), encoding: "UTF-16BE" }; + } + + try { + return { + text: new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, ""), + encoding: "UTF-8", + }; + } catch { + return { + text: new TextDecoder("gb18030").decode(bytes).replace(/^\uFEFF/, ""), + encoding: "GB18030", + }; + } + } + + function parseCSV(text) { + const source = String(text ?? ""); + const rows = []; + let row = []; + let field = ""; + let quoted = false; + + for (let index = 0; index < source.length; index += 1) { + const current = source[index]; + const next = source[index + 1]; + + if (current === '"') { + if (quoted && next === '"') { + field += '"'; + index += 1; + } else if (quoted) { + quoted = false; + } else if (field.length === 0) { + quoted = true; + } else { + field += current; + } + continue; + } + + if (current === "," && !quoted) { + row.push(field); + field = ""; + continue; + } + + if ((current === "\n" || current === "\r") && !quoted) { + if (current === "\r" && next === "\n") index += 1; + row.push(field); + rows.push(row); + row = []; + field = ""; + continue; + } + + field += current; + } + + if (quoted) throw new Error("CSV 中存在未闭合的双引号"); + if (field.length > 0 || row.length > 0 || source.length === 0) { + row.push(field); + rows.push(row); + } + return rows; + } + + function csvToRecords(text) { + const rows = parseCSV(text); + const rawHeaders = rows.shift() ?? []; + const headers = rawHeaders.map(normalizeHeader); + const records = []; + + rows.forEach((cells, index) => { + if (cells.every((cell) => String(cell ?? "").trim() === "")) return; + const record = { __row: index + 2 }; + headers.forEach((header, column) => { + if (header) record[header] = String(cells[column] ?? "").trim(); + }); + records.push(record); + }); + return { headers, records }; + } + + function splitKeywords(value) { + return String(value ?? "").split(",").map((keyword) => keyword.trim()).filter(Boolean); + } + + function validateRecords(parsed) { + const headers = parsed?.headers ?? []; + const records = parsed?.records ?? []; + const errors = []; + const warnings = []; + const validRecords = []; + const seenHeaders = new Set(); + const duplicateHeaders = new Set(); + + headers.forEach((header) => { + if (!header) return; + if (seenHeaders.has(header)) duplicateHeaders.add(header); + seenHeaders.add(header); + }); + + for (const header of REQUIRED_HEADERS) { + if (!headers.includes(header)) errors.push({ row: 1, field: header, message: `缺少表头 ${header}` }); + } + for (const header of duplicateHeaders) { + errors.push({ row: 1, field: header, message: `表头 ${header} 重复` }); + } + if (records.length === 0) errors.push({ row: null, field: null, message: "CSV 中没有可处理的数据行" }); + + const seenFiles = new Map(); + for (const sourceRecord of records) { + const rowErrors = []; + const row = sourceRecord.__row; + const fileName = String(sourceRecord.file_name ?? "").trim(); + const title = String(sourceRecord.title ?? "").trim(); + const keywords = splitKeywords(sourceRecord.keywords); + const uniqueKeywords = []; + const keywordSet = new Set(); + + for (const keyword of keywords) { + const key = keyword.toLocaleLowerCase("zh-CN"); + if (keywordSet.has(key)) continue; + keywordSet.add(key); + uniqueKeywords.push(keyword); + } + + if (!fileName) rowErrors.push({ row, field: "file_name", message: "file_name 不能为空" }); + if (!title) rowErrors.push({ row, field: "title", message: "title 不能为空" }); + if (!String(sourceRecord.keywords ?? "").trim()) { + rowErrors.push({ row, field: "keywords", message: "keywords 不能为空" }); + } else if (uniqueKeywords.length < 5) { + rowErrors.push({ row, field: "keywords", message: "关键词少于 5 个" }); + } else if (uniqueKeywords.length > 35) { + rowErrors.push({ row, field: "keywords", message: "关键词多于 35 个" }); + } + + if (keywords.length !== uniqueKeywords.length) { + warnings.push({ row, field: "keywords", message: "已自动移除重复关键词" }); + } + + if (fileName) { + const fileKey = fileName.toLocaleLowerCase("zh-CN"); + if (seenFiles.has(fileKey)) { + rowErrors.push({ row, field: "file_name", message: `文件名与第 ${seenFiles.get(fileKey)} 行重复` }); + } else { + seenFiles.set(fileKey, row); + } + } + + errors.push(...rowErrors); + if (rowErrors.length === 0) { + validRecords.push({ file_name: fileName, title, keywords: uniqueKeywords.join(",") }); + } + } + + return { errors, warnings, validRecords }; + } + + return { REQUIRED_HEADERS, csvToRecords, decodeCSVBuffer, normalizeHeader, parseCSV, splitKeywords, validateRecords }; +}); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..04d3442 --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "VCG 上传助手", + "version": "1.1.0", + "description": "从本地 CSV 辅助填写 VCG 草稿的标题与关键词。", + "minimum_chrome_version": "109", + "homepage_url": "https://github.com/KanadeK/VCGVisionHelper", + "action": { + "default_popup": "popup.html" + }, + "permissions": [ + "activeTab" + ], + "content_scripts": [ + { + "matches": [ + "*://*.vcg.com/*" + ], + "js": [ + "content.js" + ], + "run_at": "document_idle" + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f386b55 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "vcg-vision-helper", + "version": "1.1.0", + "private": true, + "description": "Local-first Chrome extension for assisting VCG contributor metadata entry", + "scripts": { + "lint": "node scripts/check.mjs", + "test": "node --test", + "build": "node scripts/build.mjs", + "check": "node scripts/check.mjs && node --test && node scripts/build.mjs" + }, + "engines": { "node": ">=20" }, + "license": "MIT" +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..390063a --- /dev/null +++ b/popup.css @@ -0,0 +1,75 @@ +:root { + color-scheme: light dark; + --surface: #f8fafc; + --surface-raised: #eef2f7; + --surface-strong: #e2e8f0; + --text: #172033; + --text-muted: #526077; + --border: #cbd5e1; + --accent: #2457c5; + --accent-hover: #1f49a5; + --accent-text: #f8fafc; + --success: #16794a; + --danger: #b42318; + --focus: #4f7ee8; + font-family: "Segoe UI Variable", "Segoe UI", "Microsoft YaHei UI", sans-serif; +} + +* { box-sizing: border-box; } +body { width: 390px; max-height: 600px; margin: 0; overflow-y: auto; color: var(--text); background: var(--surface); } +button, input, summary { font: inherit; } +button, summary, .file-picker { -webkit-tap-highlight-color: transparent; } +.app-shell { padding: 18px; } +.app-header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } +.brand-mark { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 14px; color: var(--accent-text); background: var(--accent); font-size: 13px; font-weight: 750; letter-spacing: .04em; } +h1, h2, h3, p { margin: 0; } +h1 { font-size: 20px; line-height: 1.2; letter-spacing: -.02em; } +.app-header p { margin-top: 4px; color: var(--text-muted); font-size: 12px; } +.import-section { padding: 16px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface-raised); } +.section-heading, .preview-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +h2 { font-size: 15px; } +h3 { font-size: 13px; } +.privacy-note, .preview-heading span { color: var(--text-muted); font-size: 11px; } +.file-picker { display: block; margin-top: 14px; padding: 14px; border: 1px dashed var(--border); border-radius: 12px; cursor: pointer; background: var(--surface); transition: border-color 150ms ease, transform 150ms ease; } +.file-picker:hover { border-color: var(--accent); } +.file-picker:active { transform: translateY(1px); } +.file-picker-title, .file-picker-help { display: block; } +.file-picker-title { color: var(--accent); font-size: 13px; font-weight: 700; } +.file-picker-help { margin-top: 4px; color: var(--text-muted); font-size: 11px; line-height: 1.45; } +#csvFile { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } +.file-picker:has(+ #csvFile:focus-visible) { outline: 3px solid color-mix(in srgb, var(--focus) 40%, transparent); outline-offset: 2px; } +.file-meta { margin-top: 10px; color: var(--text-muted); font-size: 11px; overflow-wrap: anywhere; } +.status { margin-top: 12px; padding: 10px 12px; border-radius: 12px; color: var(--text-muted); background: var(--surface-strong); font-size: 12px; line-height: 1.45; } +.status[data-tone="success"] { color: var(--success); } +.status[data-tone="error"] { color: var(--danger); } +.status[data-tone="working"] { color: var(--accent); } +.issues { margin-top: 10px; padding: 10px 12px; border-left: 3px solid var(--danger); border-radius: 0 10px 10px 0; color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--surface)); font-size: 11px; line-height: 1.5; } +.issues p + p { margin-top: 5px; } +.preview { margin-top: 14px; } +.table-wrap { margin-top: 8px; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } +table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 10px; } +th, td { padding: 8px; overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +th { color: var(--text-muted); background: var(--surface-strong); font-weight: 650; } +tbody tr + tr { border-top: 1px solid var(--border); } +.primary-button { width: 100%; margin-top: 14px; padding: 11px 16px; border: 0; border-radius: 12px; color: var(--accent-text); background: var(--accent); cursor: pointer; font-weight: 750; transition: background 150ms ease, transform 150ms ease; } +.primary-button:hover:not(:disabled) { background: var(--accent-hover); } +.primary-button:active:not(:disabled) { transform: translateY(1px); } +.primary-button:disabled { cursor: not-allowed; opacity: .5; } +.primary-button:focus-visible, summary:focus-visible, .feedback-link:focus-visible { outline: 3px solid color-mix(in srgb, var(--focus) 45%, transparent); outline-offset: 2px; } +.submit-note { margin-top: 8px; color: var(--text-muted); font-size: 10px; line-height: 1.45; text-align: center; } +.support-section { margin-top: 12px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface-raised); } +summary { padding: 13px 15px; cursor: pointer; color: var(--text-muted); font-size: 12px; font-weight: 650; } +.support-content { padding: 0 15px 15px; color: var(--text-muted); font-size: 11px; line-height: 1.5; } +.qr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; } +figure { margin: 0; text-align: center; } +figure img { display: block; width: 100%; aspect-ratio: 1; border-radius: 12px; object-fit: cover; } +figcaption { margin-top: 5px; } +.feedback-link { display: inline-block; margin-top: 12px; color: var(--accent); } + +@media (prefers-color-scheme: dark) { + :root { --surface: #101725; --surface-raised: #172033; --surface-strong: #222d42; --text: #edf2f8; --text-muted: #b3bfd1; --border: #3a4760; --accent: #6f96ef; --accent-hover: #86a8f2; --accent-text: #101725; --success: #66d59b; --danger: #ff8f86; --focus: #8aacf5; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; } +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..880d4b8 --- /dev/null +++ b/popup.html @@ -0,0 +1,80 @@ + + +
+ + +本地 CSV 批量填写
+| 文件名 | +标题 | +关键词 | +
|---|
扩展不会自动提交,请在处理后人工检查。
+