From a2809f149338377675a6ede39ff6743510cc3e28 Mon Sep 17 00:00:00 2001 From: LydiaTools <311246217+LydiaTools@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:33:13 +0800 Subject: [PATCH] Add attributed Stack Overflow research desk --- .github/workflows/check.yml | 3 + README.md | 10 ++- README.zh-CN.md | 10 ++- app/app.js | 1 + app/index.html | 6 +- app/stackoverflow-app.js | 64 +++++++++++++++++++ app/stackoverflow-core.js | 105 ++++++++++++++++++++++++++++++++ app/stackoverflow.css | 1 + app/stackoverflow.html | 43 +++++++++++++ app/styles.css | 1 + docs/FORMAT.md | 8 +++ package.json | 5 +- scripts/serve.cjs | 4 ++ tests/stackoverflow-browser.cjs | 38 ++++++++++++ tests/stackoverflow.test.cjs | 43 +++++++++++++ 15 files changed, 331 insertions(+), 11 deletions(-) create mode 100644 app/stackoverflow-app.js create mode 100644 app/stackoverflow-core.js create mode 100644 app/stackoverflow.css create mode 100644 app/stackoverflow.html create mode 100644 tests/stackoverflow-browser.cjs create mode 100644 tests/stackoverflow.test.cjs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 438829d..f96a37e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -57,6 +57,9 @@ jobs: - run: npm run test:hn-find-repos env: BROWSER_CHANNEL: chromium + - run: npm run test:stackoverflow + env: + BROWSER_CHANNEL: chromium - uses: actions/upload-artifact@v7.0.2 if: always() with: diff --git a/README.md b/README.md index 78cd8fc..29efc24 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,15 @@ Find a lead. Read what happened next. Save the evidence and your own judgment— Start with a topic, a project, or a question you cannot quite let go of. You do the thinking; GapCue helps you keep the trail. -1. **Find a lead.** Search public GitHub repositories or Hacker News for a topic such as `PDF tables`. Follow a repository to related HN searches, or look for GitHub candidates from an HN discussion. -2. **Read what happened next.** Open an HN story or paste a public GitHub Issue URL. Review sampled replies and mentioned changes, then follow the source when you need more context. +1. **Find a lead.** Search public GitHub repositories, Hacker News, or Stack Overflow questions for a topic such as `PDF tables`. Follow a repository to related HN searches, or look for GitHub candidates from an HN discussion. +2. **Read what happened next.** Open an HN story or Stack Overflow question, or paste a public GitHub Issue URL. Review sampled replies and mentioned changes, then follow the source when you need more context. 3. **Make your call.** Compare what you found with existing options. Choose **Follow up**, **Prototype candidate**, or **Pass for my task**, and add your own note. 4. **Pick it up again.** Save the review file with its source snapshot and your notes. You can reopen it later, even offline. You can also save and [compare repository search snapshots](#see-how-a-project-changes-over-time). When you come back to an idea, you can see what you found—and why you made that decision. +The Stack Overflow desk reads a question and up to 10 answers sorted by votes. Each result links to its source and author, with the post's CC BY-SA license shown. It is a sample, not a complete discussion or a measure of demand. This browser feature is online; the v0.6.2 ZIP predates it. +
See how to move between Hacker News and GitHub @@ -114,7 +116,9 @@ CLI exit codes: `0` completed reads/local save; `1` partial or unavailable sourc | --- | --- | | Find candidate repositories | GitHub repository search, creation period and current total Stars | | Find projects discussed elsewhere | Hacker News keyword search, relevant/recent order and an explicit time period | +| Search developer questions | Stack Overflow question search, with author, source and license attribution | | Read a real thread without installing anything | Paste one public GitHub Issue or HN story URL into the browser | +| Read developer answers | Stack Overflow question plus up to 10 vote-sorted answers, with links to the original posts | | Collect several GitHub issues | Python CLI: explicit issue URLs, or bounded search within one public repository | | Check what happened later | First and last comment pages, shown with the newest collected comments first | | Read a reply in context | Show its collected parent, or read one public HN parent on demand | @@ -124,7 +128,7 @@ CLI exit codes: `0` completed reads/local save; `1` partial or unavailable sourc | Return to the same evidence | Download and reopen a review containing the exact report text, its SHA-256 and your notes | | Work in either language | English/Chinese controls; original source language is preserved | -The browser app uses plain HTML, CSS and JavaScript: no build step, model API, database or account setup. Public reads happen only after you search, choose a discussion, check a linked GitHub repository, request an HN parent comment, or request the AgentAtlas snapshot. Search terms go to the selected GitHub or HN Search service; the snapshot request goes to AgentAtlas without search terms. Discussion requests go to GitHub, HN Search or the official HN API. Cookies and authentication are omitted. Reports and notes are not uploaded or saved automatically. Parent comments fetched on demand are shown live and are not added to the saved review; an already-collected parent comes from the original snapshot. The optional multi-issue collector uses Python's standard library. +The browser app uses plain HTML, CSS and JavaScript: no build step, model API, database or account setup. Public reads happen only after you search, choose a discussion, check a linked GitHub repository, request an HN parent comment, or request the AgentAtlas snapshot. Search terms go to the selected GitHub, Hacker News Search or Stack Exchange API; the snapshot request goes to AgentAtlas without search terms. Selecting a Stack Overflow result requests that question and its answers. The page shows source, author and license links, converts post HTML to plain text, and marks shortened excerpts. Cookies and authentication are omitted. Reports and notes are not uploaded or saved automatically. The Stack Overflow viewer stores hashed request fingerprints and source backoff times in local browser storage to avoid repeating searches too soon. Its downloaded review is a separate format, opened only in the Stack Overflow viewer; older CLI reports are not compatible. Parent comments fetched on demand are shown live and are not added to the saved review; an already-collected parent comes from the original snapshot. The optional multi-issue collector uses Python's standard library. ## Try the offline review workflow diff --git a/README.zh-CN.md b/README.zh-CN.md index c854f7d..ee3ee19 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,13 +22,15 @@ 从一个话题、一个项目,或者一个让你放不下的问题出发。判断由你来做,GapCue 帮你把线索留好。 -1. **先找线索。** 搜索 GitHub 公开仓库或 Hacker News,例如输入 `PDF tables`。从仓库查找 HN 讨论,也可以从 HN 讨论查找 GitHub 候选仓库。 -2. **看看后来发生了什么。** 打开 HN 帖子,或粘贴公开的 GitHub Issue 链接。看看抽样评论和提到的变更;需要更多上下文时,回到原始来源。 +1. **先找线索。** 搜索 GitHub 公开仓库、Hacker News 讨论或 Stack Overflow 问题,例如输入 `PDF tables`。从仓库查找 HN 讨论,也可以从 HN 讨论查找 GitHub 候选仓库。 +2. **看看后来发生了什么。** 打开 HN 帖子或 Stack Overflow 问题,或粘贴公开的 GitHub Issue 链接。看看抽样评论和提到的变更;需要更多上下文时,回到原始来源。 3. **留下你的判断。** 对照讨论和现有方案,选择“继续核对”“候选小样”或“不适合当前任务”,并写下自己的想法。 4. **下次从这里继续。** 保存包含来源快照和笔记的评审文件;之后即使离线,也能重新打开。你还可以保存并[对比仓库搜索快照](#看一个项目随时间发生了什么变化)。 等你回来看这个点子,还能找到当时看到了什么、为什么这样决定。 +Stack Overflow 调研页会读取问题和最多 10 条按票数排序的回答。每条结果都保留原文、作者和内容许可证链接。它是讨论样本,不是完整讨论,也不是需求度量。该浏览器功能已上线网页,v0.6.2 ZIP 尚未包含。 +
看看如何在 Hacker News 和 GitHub 之间寻找线索 @@ -114,7 +116,9 @@ node scripts/research.cjs review ./thread-01/review.json --verdict followup --no | --- | --- | | 发现候选仓库 | GitHub 仓库搜索,按创建时间和当前累计 Star 查看 | | 在其他平台发现项目 | Hacker News 主题搜索,可选相关/最新和时间范围 | +| 搜索开发者问题 | Stack Overflow 问题搜索,显示作者、原文和许可证链接 | | 不安装工具就读真实讨论 | 在网页粘贴一个公开 GitHub Issue 或 HN 帖子地址 | +| 阅读开发者回答 | 查看 Stack Overflow 问题及最多 10 条按票数排序的回答,并可回到每条原文 | | 批量采集 GitHub 问题 | 可选 Python 采集器:指定地址或限定一个仓库搜索 | | 查看后续讨论 | 采集首尾评论页,优先展示已采到的最新评论 | | 看懂回复的上文 | 展示已采集的上文,或按需读取一条公开 HN 上文评论 | @@ -124,7 +128,7 @@ node scripts/research.cjs review ./thread-01/review.json --verdict followup --no | 下次接着看 | 评审文件保留原始报告文本、SHA-256 和笔记,可重新打开 | | 分享给海外用户 | 中英文界面和文档,来源语言保持原样 | -网页使用原生 HTML/CSS/JavaScript,无需构建、数据库、模型 API 或账号。点击搜索、读取讨论、查询链接的 GitHub 仓库、查看 HN 上文或请求 AgentAtlas 快照后才请求公开来源:搜索词发给选择的 GitHub 或 HN Search;快照请求发给 AgentAtlas,不包含搜索词;讨论来源请求发给 GitHub、HN Search 或 HN 官方 API。请求不携带 Cookie 或认证信息。报告与笔记不上传、不自动持久保存。按需读取的上文为实时展示,不会加入保存的评审文件;如果上文已在采集样本中,则展示原始快照。可选批量采集器仅依赖 Python 标准库。 +网页使用原生 HTML/CSS/JavaScript,无需构建、数据库、模型 API 或账号。点击搜索、读取讨论、查询链接的 GitHub 仓库、查看 HN 上文或请求 AgentAtlas 快照后才请求公开来源:搜索词会发给 GitHub、Hacker News Search 或 Stack Exchange API;选择 Stack Overflow 问题后,工具再读取问题和回答;快照请求发给 AgentAtlas,不包含搜索词。页面为 Stack Overflow 每条内容提供来源、作者和许可证链接,将 HTML 转为纯文本,并标记缩短的片段。请求不携带 Cookie 或认证信息。为避免短时间重复搜索,Stack Overflow 页面会在浏览器本地存储已哈希的请求指纹和来源要求的暂停时间。报告与笔记不上传、不自动持久保存。Stack Overflow 的下载评审文件使用独立格式,只能在对应页面打开;旧版 CLI 报告不兼容。按需读取的 HN 上文为实时展示,不会加入保存的评审文件;如果上文已在采集样本中,则展示原始快照。可选批量采集器仅依赖 Python 标准库。 ## 试用离线评审流程 diff --git a/app/app.js b/app/app.js index 4543b4b..a31d12a 100644 --- a/app/app.js +++ b/app/app.js @@ -141,6 +141,7 @@ function render() { document.documentElement.lang=lang==="zh"?"zh-CN":"en"; for(const node of document.querySelectorAll("[data-i18n]"))node.textContent=tr(node.dataset.i18n); $("language").textContent=lang==="en"?"中文":"English"; + $("stackLink").href="stackoverflow.html"+(lang==="zh"?"?lang=zh":""); for(const option of $("verdict").options)option.textContent=tr(option.value); $("notes").placeholder=tr("notePlaceholder"); if(!loaded)return; diff --git a/app/index.html b/app/index.html index 426fcc0..7832a7d 100644 --- a/app/index.html +++ b/app/index.html @@ -3,8 +3,8 @@ - - + + GapCue — Project research desk @@ -17,7 +17,7 @@
GapCueGitHub Issue Scout - +
diff --git a/app/stackoverflow-app.js b/app/stackoverflow-app.js new file mode 100644 index 0000000..6143c3a --- /dev/null +++ b/app/stackoverflow-app.js @@ -0,0 +1,64 @@ +/* MIT © 2026 LydiaTools. Source text is always displayed as text. */ +(function(){ + 'use strict'; + const so=window.GapCueStack, live=window.GapCueLive, $=id=>document.getElementById(id); + let lang=new URLSearchParams(location.search).get('lang')==='zh'?'zh':'en'; + let source=null,dirty=false,editVersion=0,openVersion=0,operation=null,operationId=0,searchResult=null,statusKey=null,statusArgs=[],saveKey=null; + const words={ + en:{subtitle:'Project research desk',back:'Research desk',headline:'Find the question behind the project.',intro:'Search Stack Overflow, read the question and answers, then follow project links.',open:'Open saved research',query:'What are you researching?',period:'Created',days90:'Past 90 days',days30:'Past 30 days',days365:'Past year',days0:'Any time',search:'Search questions',stop:'Stop',hint:'Search results come from Stack Overflow.',results:'Questions',sourceLabel:'Stack Overflow question',openSource:'Open original',answers:'Answers',findRepos:'Find related projects',repoHint:"Links in the source are leads. Check each project's own page and license.",githubSearch:'Search GitHub for this topic',decision:'Your decision',next:'Next step',unreviewed:'Not reviewed',followup:'Follow up',prototype:'Prototype candidate',pass:'Pass for my task',notes:'What would change your decision?',save:'Save research file',attribution:'Question and answer content from Stack Overflow, with each author’s license shown. Search terms are sent to Stack Exchange only when you search.',placeholder:'e.g. browser agent',searching:'Searching Stack Overflow…',collecting:'Reading the question and answers…',stopped:'Stopped. Your current research is unchanged.',ready:'Select a question to read.',noResults:'No questions found. Try another topic or period.',more:'More results may exist on Stack Overflow.',answered:n=>`${n} answers reported`,coverage:(n,total)=>`${n} of ${total} reported answers collected`,moreAnswers:'More answers exist; this sample is not the whole discussion.',partial:'Some answers could not be read. The question is retained.',zeroAnswers:'No answers were returned.',author:'By',license:'License',accepted:'Accepted by asker',repos:'GitHub links mentioned',checkStars:'Check current Stars',checking:'Reading GitHub…',stars:(n,d)=>`${n.toLocaleString()} Stars · checked ${d}`,repoFailed:'Could not read GitHub. Open the original link or try later.',saved:'Research file prepared.',edited:'Unsaved changes.',restored:'Saved research opened.',invalidInput:'Enter a topic between 2 and 120 characters.',storageUnavailable:'Browser storage is needed to respect the source’s request limits.',backoff:n=>`Stack Exchange requested a pause. Try again in ${n} seconds.`,duplicate:n=>`This search was requested recently. Try again in ${n} seconds.`,rateLimited:'Stack Exchange API quota or rate limit reached. Try later.',sourceFailed:'The source could not be read. Your current research is unchanged.',timeout:'The source timed out. Your current research is unchanged.',responseTooLarge:'The source exceeds the 1 MiB read limit. Open the original.',licenseUnavailable:'The source did not provide its content license. Open the original.',unavailable:'The question is unavailable.',invalidSnapshot:'Could not open this research file.',discard:'Replace the current research and unsaved notes? Save the research file first to keep them.',fileLarge:'Choose a research file smaller than 2 MiB.',noRepos:'No direct GitHub repository links found in the collected text.',sourceAt:'Source collected',shortened:'Excerpt shortened',formatChange:'Post formatting is shown as plain text; links stay inline.',origin:'Source: Stack Overflow',checkState:'API status and answers can change. An accepted answer does not establish a released fix.'}, + zh:{subtitle:'项目需求调研台',back:'返回调研台',headline:'从问题出发,寻找项目。',intro:'搜索 Stack Overflow,阅读问题和回答,再沿着链接查找项目。',open:'打开已保存的调研',query:'想调研什么?',period:'创建时间',days90:'近 90 天',days30:'近 30 天',days365:'近一年',days0:'不限时间',search:'搜索问题',stop:'停止',hint:'搜索结果来自 Stack Overflow。',results:'问题',sourceLabel:'Stack Overflow 问题',openSource:'查看原文',answers:'回答',findRepos:'查找相关项目',repoHint:'原文链接只是线索,请核对项目主页及许可证。',githubSearch:'按主题搜索 GitHub',decision:'你的判断',next:'下一步',unreviewed:'尚未评审',followup:'继续核对',prototype:'候选小样',pass:'不适合当前任务',notes:'什么信息会改变你的判断?',save:'保存调研文件',attribution:'问题和回答来自 Stack Overflow,逐条显示作者及许可证。只在搜索时向 Stack Exchange 发送搜索词。',placeholder:'例如 browser agent',searching:'正在搜索 Stack Overflow…',collecting:'正在读取问题和回答…',stopped:'已停止,当前调研未改变。',ready:'选择一个问题查看。',noResults:'没有找到问题,请换个主题或时间范围。',more:'Stack Overflow 上可能还有更多结果。',answered:n=>`报告 ${n} 条回答`,coverage:(n,total)=>`已采集 ${n} / ${total} 条回答`,moreAnswers:'还有更多回答;本样本不是完整讨论。',partial:'部分回答未能读取,问题仍然保留。',zeroAnswers:'没有返回回答。',author:'作者',license:'许可证',accepted:'提问者已采纳',repos:'原文提到的 GitHub 链接',checkStars:'查看当前 Star',checking:'正在读取 GitHub…',stars:(n,d)=>`${n.toLocaleString()} Star · 核查于 ${d}`,repoFailed:'无法读取 GitHub,可打开原链接或稍后重试。',saved:'调研文件已准备好。',edited:'修改尚未保存。',restored:'已打开保存的调研。',invalidInput:'请输入 2–120 字符的主题。',storageUnavailable:'需要浏览器存储,以遵守来源的请求频率限制。',backoff:n=>`Stack Exchange 要求暂停请求,请在 ${n} 秒后再试。`,duplicate:n=>`最近刚请求过相同内容,请在 ${n} 秒后再试。`,rateLimited:'Stack Exchange API 额度或频率已受限,请稍后再试。',sourceFailed:'无法读取来源,当前调研未改变。',timeout:'来源读取超时,当前调研未改变。',responseTooLarge:'来源超过 1 MiB 读取上限,请查看原文。',licenseUnavailable:'来源未提供内容许可证,请查看原文。',unavailable:'该问题暂不可用。',invalidSnapshot:'无法打开此调研文件。',discard:'替换当前调研和未保存的笔记吗?要保留进度,请先保存调研文件。',fileLarge:'请选择小于 2 MiB 的调研文件。',noRepos:'已采集的原文中没有直接指向 GitHub 仓库的链接。',sourceAt:'来源采集于',shortened:'原文片段已缩短',formatChange:'原文格式已转为纯文本,链接地址保留在文字中。',origin:'来源:Stack Overflow',checkState:'API 状态和回答可能变化;采纳回答不等于修复已经发布。'} + }; + const tr=(k,...a)=>typeof words[lang][k]==='function'?words[lang][k](...a):words[lang][k]; + const node=(tag,value,cls)=>{const n=document.createElement(tag);if(value!==undefined)n.textContent=String(value);if(cls)n.className=cls;return n;}; + const date=v=>{const d=Date.parse(v);return Number.isFinite(d)?new Date(d).toISOString().slice(0,16).replace('T',' ')+' UTC':'—';}; + function link(label,href){const a=node('a',label);a.href=href;a.target='_blank';a.rel='noopener noreferrer';return a;} + function setStatus(key,...args){statusKey=key;statusArgs=args;$('status').textContent=tr(key,...args);} + function setSaveStatus(key){saveKey=key;$('saveStatus').textContent=key?tr(key):'';} + function cancel(){operationId++;operation?.abort();operation=null;$('stop').hidden=true;$('search').disabled=false;} + function busy(){cancel();const id=operationId;operation=new AbortController();$('search').disabled=true;$('stop').hidden=false;return {id,signal:operation.signal};} + function finish(id){if(id===operationId){operation=null;$('search').disabled=false;$('stop').hidden=true;}} + function error(e){const k=words[lang][e?.code]?e.code:'sourceFailed';setStatus(k,e?.wait||0);} + function confirmDiscard(){return !dirty||window.confirm(tr('discard'));} + function credit(parent,attribution,sourceUrl,created){ + const box=node('div',undefined,'stack-credit');box.append(document.createTextNode(tr('author')+' ')); + if(attribution.author.url)box.append(link(attribution.author.name,attribution.author.url));else box.append(document.createTextNode(attribution.author.name)); + box.append(document.createTextNode(' · '+date(created)+' · '+tr('license')+' '),link(attribution.license,so.licenses[attribution.license]||'https://stackoverflow.com/help/licensing'),document.createTextNode(' · '),link(tr('origin'),sourceUrl));parent.append(box); + } + function renderBody(parent,item){ + parent.append(node('pre',item.body.text,'source-excerpt'),node('p',tr('formatChange'),'hint'));if(item.body.truncated)parent.append(node('p',tr('shortened'),'hint')); + if(item.repositories.length){parent.append(node('p',tr('repos'),'hint'));for(const url of item.repositories){const row=node('div',undefined,'stack-repo'),a=link(url.replace('https://github.com/',''),url),b=node('button',tr('checkStars'),'secondary'),status=node('span','','hint');b.type='button';b.addEventListener('click',async()=>{b.disabled=true;status.textContent=tr('checking');try{const info=await live.linkedRepositoryInfo(url);if(row.isConnected)status.textContent=tr('stars',info.stars,date(info.captured_at));}catch{if(row.isConnected)status.textContent=tr('repoFailed');}finally{if(row.isConnected)b.disabled=false;}});row.append(a,b,status);parent.append(row);}} + } + function renderSource(){ + if(!source){$('researchDesk').hidden=true;return;}$('researchDesk').hidden=false; + const q=source.question;$('questionTitle').textContent=q.title;$('questionLink').href=q.url;$('questionCredit').replaceChildren();credit($('questionCredit'),q.attribution,q.url,q.created_at); + $('questionBody').replaceChildren();renderBody($('questionBody'),q);$('questionRepos').replaceChildren(); + const answers=$('answers');answers.replaceChildren();$('answerCoverage').textContent=tr('coverage',source.answers.length,q.answer_count); + if(source.partial)answers.append(node('p',tr('partial'),'hint'));else if(!source.answers.length)answers.append(node('p',tr('zeroAnswers'),'hint')); + for(const a of source.answers){const article=node('article',undefined,'stack-answer');if(a.accepted)article.append(node('span',tr('accepted'),'badge'));credit(article,a.attribution,a.url,a.created_at);renderBody(article,a);answers.append(article);} + if(source.answers_has_more||source.answers.length{n.textContent=tr(n.dataset.i18n);});$('homeLink').href='./'+(lang==='zh'?'?lang=zh':'');$('query').placeholder=tr('placeholder');$('language').textContent=lang==='en'?'中文':'English';$('language').setAttribute('aria-label',lang==='en'?'切换至中文':'Switch to English');if(statusKey)setStatus(statusKey,...statusArgs);if(saveKey)setSaveStatus(saveKey);if(searchResult)renderResults(searchResult);renderSource(); + } + function renderResults(result){ + searchResult=result;$('resultsSection').hidden=false;$('resultsMeta').textContent=tr('origin')+' · '+date(result.captured_at)+(result.has_more?' · '+tr('more'):'');const list=$('results');list.replaceChildren(); + if(!result.entries.length){list.append(node('p',tr('noResults'),'hint'));return;} + for(const item of result.entries){const row=node('div',undefined,'stack-results-item'),button=node('button',item.title,'result-open'),meta=node('p',undefined,'hint');button.type='button';button.addEventListener('click',()=>readQuestion(item.id,result.query));meta.append(document.createTextNode(tr('author')+' '));if(item.attribution.author.url)meta.append(link(item.attribution.author.name,item.attribution.author.url));else meta.append(document.createTextNode(item.attribution.author.name));meta.append(document.createTextNode(' · '+date(item.created_at)+' · '+tr('license')+' '),link(item.attribution.license,so.licenses[item.attribution.license]||'https://stackoverflow.com/help/licensing'),' · '+tr('answered',item.answer_count));row.append(button,meta,link(tr('openSource'),item.url));list.append(row);} + } + async function readQuestion(id,query){ + if(!confirmDiscard())return;const version=editVersion,op=busy();setStatus('collecting'); + try{const found=await so.collect(id,{signal:op.signal});if(op.id!==operationId)return;if(version!==editVersion&&!confirmDiscard())return;found.search_query=query;so.validate(found);source=found;$('notes').value='';$('verdict').value='unreviewed';dirty=false;renderSource();setSaveStatus(null);setStatus(found.partial?'partial':'ready');} + catch(e){if(op.id===operationId)error(e);}finally{finish(op.id);} + } + $('searchForm').addEventListener('submit',async event=>{event.preventDefault();const op=busy();setStatus('searching');try{const result=await so.search($('query').value,$('days').value,{signal:op.signal});if(op.id!==operationId)return;renderResults(result);setStatus('ready');}catch(e){if(op.id===operationId)error(e);}finally{finish(op.id);}}); + $('stop').addEventListener('click',()=>{cancel();setStatus('stopped');}); + $('notes').addEventListener('input',()=>{dirty=true;editVersion++;setSaveStatus('edited');});$('verdict').addEventListener('change',()=>{dirty=true;editVersion++;setSaveStatus('edited');}); + $('save').addEventListener('click',async()=>{if(!source)return;const selectedSource=source;try{so.validate(selectedSource);const version=editVersion,sourceText=JSON.stringify(selectedSource),verdict=$('verdict').value,notes=$('notes').value,bundle={schema:1,tool:'GapCueStackOverflowReview',source_text:sourceText,source_sha256:await so.hash(sourceText),verdict,notes};const blob=new Blob([JSON.stringify(bundle,null,2)+'\n'],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='gapcue-stackoverflow-'+selectedSource.question.id+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),2000);if(source===selectedSource&&version===editVersion){dirty=false;setSaveStatus('saved');}else if(source===selectedSource)setSaveStatus('edited');}catch(e){error(e);}}); + $('open').addEventListener('click',()=>{if(confirmDiscard()){cancel();openVersion=editVersion;$('file').click();}}); + $('file').addEventListener('change',async()=>{try{const file=$('file').files[0];if(!file)return;if(file.size>2*1024*1024){setStatus('fileLarge');return;}const bundle=JSON.parse(await file.text());if(!bundle||bundle.schema!==1||bundle.tool!=='GapCueStackOverflowReview'||typeof bundle.source_text!=='string'||!/^[0-9a-f]{64}$/.test(bundle.source_sha256)||await so.hash(bundle.source_text)!==bundle.source_sha256||!['unreviewed','followup','prototype','pass'].includes(bundle.verdict)||typeof bundle.notes!=='string'||bundle.notes.length>6000)throw new Error('invalid');const data=so.validate(JSON.parse(bundle.source_text));if(openVersion!==editVersion&&!confirmDiscard())return;cancel();source=data;$('notes').value=bundle.notes;$('verdict').value=bundle.verdict;dirty=false;renderSource();setSaveStatus('restored');}catch{setStatus('invalidSnapshot');}finally{$('file').value='';}}); + $('language').addEventListener('click',()=>{lang=lang==='en'?'zh':'en';const url=new URL(location.href);if(lang==='zh')url.searchParams.set('lang','zh');else url.searchParams.delete('lang');history.replaceState(null,'',url);render();}); + window.addEventListener('beforeunload',event=>{if(dirty){event.preventDefault();event.returnValue='';}}); + render(); +}()); diff --git a/app/stackoverflow-core.js b/app/stackoverflow-core.js new file mode 100644 index 0000000..486bac4 --- /dev/null +++ b/app/stackoverflow-core.js @@ -0,0 +1,105 @@ +/* MIT © 2026 LydiaTools. Stack Exchange search is a bounded public read. */ +(function (root, factory) { + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api;else root.GapCueStack=api; +}(typeof globalThis!=='undefined'?globalThis:this,function(){ + 'use strict'; + const API='https://api.stackexchange.com/2.3/'; + const STORE='gapcue-stackexchange-throttle-v1'; + const LIMIT=1024*1024; + const SEARCH_FILTER='!*M*dC)MfMUxnKjPG'; + const licenses={'CC BY-SA 2.5':'https://creativecommons.org/licenses/by-sa/2.5/','CC BY-SA 3.0':'https://creativecommons.org/licenses/by-sa/3.0/','CC BY-SA 4.0':'https://creativecommons.org/licenses/by-sa/4.0/'}; + const isInt=n=>Number.isSafeInteger(n)&&n>=0; + const isId=n=>isInt(n)&&n>0; + const text=(v,max)=>typeof v==='string'&&v.length<=max; + class SourceError extends Error{constructor(code,wait=0){super(code);this.code=code;this.wait=wait;}} + function questionUrl(value){ + if(!text(value,1500))return null; + try{const u=new URL(value),m=u.pathname.match(/^\/(?:questions|q)\/([1-9][0-9]*)(?:\/[^/?#]+)?\/?$/); + return u.protocol==='https:'&&u.hostname==='stackoverflow.com'&&!u.username&&!u.password&&!u.port&&m&&isId(Number(m[1]))?'https://stackoverflow.com/questions/'+m[1]:null; + }catch{return null;} + } + function personUrl(value){ + if(!text(value,1500))return null; + try{const u=new URL(value);return u.protocol==='https:'&&u.hostname==='stackoverflow.com'&&!u.username&&!u.password&&!u.port&&/^\/users\/[1-9][0-9]*(?:\/[^/?#]+)?\/?$/.test(u.pathname)?u.origin+u.pathname:null;}catch{return null;} + } + function repoUrl(value){ + try{const u=new URL(value),m=u.pathname.match(/^\/([A-Za-z0-9_-]{1,39})\/([A-Za-z0-9_.-]{1,100})(?:\/|$)/); + if(u.protocol!=='https:'||u.hostname!=='github.com'||u.username||u.password||u.port||!m||m[2]==='.'||m[2]==='..')return null; + return 'https://github.com/'+m[1]+'/'+m[2].replace(/\.git$/i,''); + }catch{return null;} + } + function repos(html){ + const found=[];for(const match of String(html||'').matchAll(/https:\/\/github\.com\/[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+(?:\/[^\s"'<>)]*)?/gi)){ + const u=repoUrl(match[0]);if(u&&!found.some(x=>x.toLowerCase()===u.toLowerCase()))found.push(u);if(found.length===10)break; + }return found; + } + function plain(v){return String(v||'').replace(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,(_,url,label)=>label+' ('+url+')').replace(/<(?:p|br|\/p|\/pre|\/div|\/li)\b[^>]*>/gi,'\n').replace(/<[^>]*>/g,'').replace(/&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos|nbsp);/gi,(m,e)=>{if(e[0]==='#'){const n=e[1].toLowerCase()==='x'?parseInt(e.slice(2),16):Number(e.slice(1));return n>0&&n<=0x10ffff&&!(n>=0xd800&&n<=0xdfff)?String.fromCodePoint(n):m;}return ({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'",nbsp:' '})[e.toLowerCase()]||m;}).trim();} + function excerpt(v){const chars=[...plain(v)],max=6000;return {text:chars.slice(0,max).join(''),characters:chars.length,truncated:chars.length>max};} + function author(owner){return {name:text(owner?.display_name,200)?plain(owner.display_name).slice(0,200):'[deleted or unavailable]',url:personUrl(owner?.link)};} + function date(n){if(!isId(n)||n>253402300799)throw new SourceError('sourceFailed');return new Date(n*1000).toISOString();} + function attribution(item){if(!text(item.content_license,80)||!item.content_license.trim())throw new SourceError('licenseUnavailable');return {author:author(item.owner),license:item.content_license};} + function snapshotQuestion(item){ + const url=questionUrl(item.link);if(!isId(item.question_id)||!url||!url.endsWith('/'+item.question_id)||!text(item.title,1500)||!text(item.body,500000)||!isInt(item.answer_count))throw new SourceError('sourceFailed'); + return {id:item.question_id,title:plain(item.title).slice(0,1000),url,created_at:date(item.creation_date),answer_count:item.answer_count,body:excerpt(item.body),attribution:attribution(item),repositories:repos(item.body)}; + } + function snapshotAnswer(item,qid){ + if(!isId(item.answer_id)||item.question_id!==qid||!text(item.body,500000)||typeof item.is_accepted!=='boolean')throw new SourceError('sourceFailed'); + return {id:item.answer_id,url:'https://stackoverflow.com/a/'+item.answer_id,created_at:date(item.creation_date),accepted:item.is_accepted,body:excerpt(item.body),attribution:attribution(item),repositories:repos(item.body)}; + } + function memoryStore(){const m=new Map();return {getItem:k=>m.has(k)?m.get(k):null,setItem:(k,v)=>m.set(k,String(v))};} + async function hash(value){if(!globalThis.crypto?.subtle)throw new SourceError('storageUnavailable');const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value));return [...new Uint8Array(bytes)].map(x=>x.toString(16).padStart(2,'0')).join('');} + function throttle(storage){ + function load(){try{const raw=storage.getItem(STORE);if(!raw)return {methods:{},seen:{}};const o=JSON.parse(raw);if(!o||typeof o.methods!=='object'||typeof o.seen!=='object')throw Error();return o;}catch{throw new SourceError('storageUnavailable');}} + function save(s){try{storage.setItem(STORE,JSON.stringify(s));}catch{throw new SourceError('storageUnavailable');}} + async function reserve(method,fingerprint,now){const s=load(),until=Number(s.methods[method]||0);if(until>now)throw new SourceError('backoff',Math.ceil((until-now)/1000));const last=Number(s.seen[fingerprint]||0);if(last>now-60000)throw new SourceError('duplicate',Math.ceil((last+60000-now)/1000));s.seen=Object.fromEntries(Object.entries(s.seen).filter(([,v])=>Number(v)>now-60000).slice(-63));s.seen[fingerprint]=now;save(s);} + function pause(method,seconds,now){if(!isInt(seconds)||seconds>2147483647)throw new SourceError('sourceFailed');if(!seconds)return;const s=load();s.methods[method]=Math.max(Number(s.methods[method]||0),now+seconds*1000);save(s);} + return {reserve,pause}; + } + function browserStorage(){try{return globalThis.localStorage;}catch{return null;}} + function client({fetcher=globalThis.fetch,storage=browserStorage(),signal,now=()=>Date.now(),locks=globalThis.navigator?.locks}={}){ + if(!storage)throw new SourceError('storageUnavailable');const state=throttle(storage); + async function get(method,path,params){ + if(!['search','question','answers'].includes(method))throw new SourceError('invalidInput'); + const url=API+path+'?'+new URLSearchParams(params),keyParams=new URLSearchParams(params);if(method==='search'&&keyParams.has('q'))keyParams.set('q',keyParams.get('q').toLowerCase());const fingerprint=await hash(API+path+'?'+keyParams); + const run=async()=>{ + if(signal?.aborted)throw new DOMException('Stopped','AbortError'); + await state.reserve(method,fingerprint,now()); + const controller=new AbortController();let timed=false;const cancel=()=>controller.abort();if(signal?.aborted)throw new DOMException('Stopped','AbortError');signal?.addEventListener('abort',cancel,{once:true});const timer=setTimeout(()=>{timed=true;controller.abort();},20000); + try{const response=await fetcher(url,{method:'GET',credentials:'omit',referrerPolicy:'no-referrer',cache:'no-store',redirect:'error',signal:controller.signal,headers:{Accept:'application/json'}}); + if(Number(response.headers.get('content-length'))>LIMIT)throw new SourceError('responseTooLarge'); + const reader=response.body.getReader(),chunks=[];let size=0;while(true){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>LIMIT){await reader.cancel();throw new SourceError('responseTooLarge');}chunks.push(value);} + const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}let data;try{data=JSON.parse(new TextDecoder().decode(bytes));}catch{throw new SourceError('sourceFailed');} + if(data?.backoff!==undefined)state.pause(method,data.backoff,now()); + if(!response.ok)throw new SourceError(response.status===429||data?.error_id===502||data?.error_id===402?'rateLimited':'sourceFailed'); + if(!data||!Array.isArray(data.items)||typeof data.has_more!=='boolean')throw new SourceError('sourceFailed'); + return data; + }catch(e){if(timed)throw new SourceError('timeout');throw e;}finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel);} + }; + return locks?.request?locks.request('gapcue-stackexchange',signal?{signal}:{},run):run(); + } + return {get}; + } + async function search(query,days,options){ + query=String(query||'').trim().replace(/\s+/g,' ');if(query.length<2||query.length>120||!['0','30','90','365'].includes(String(days)))throw new SourceError('invalidInput'); + const params={site:'stackoverflow',q:query,pagesize:'8',sort:'creation',order:'desc',filter:SEARCH_FILTER};if(Number(days))params.fromdate=String((Math.floor(Date.now()/86400000)-Number(days))*86400); + const data=await client(options).get('search','search/advanced',params); + if(data.items.length>8)throw new SourceError('sourceFailed'); + const entries=data.items.map(item=>{const url=questionUrl(item.link);if(!isId(item.question_id)||!url||!url.endsWith('/'+item.question_id)||!text(item.title,1500)||!isInt(item.answer_count))throw new SourceError('sourceFailed');return {id:item.question_id,title:plain(item.title).slice(0,1000),url,created_at:date(item.creation_date),answer_count:item.answer_count,attribution:attribution(item)};}); + return {query,days:Number(days),captured_at:new Date().toISOString(),has_more:data.has_more,entries}; + } + async function collect(id,options){ + if(!isId(id))throw new SourceError('invalidInput');const c=client(options),q=await c.get('question','questions/'+id,{site:'stackoverflow',filter:'withbody'}); + if(q.items.length!==1)throw new SourceError('unavailable');const question=snapshotQuestion(q.items[0]);if(question.id!==id)throw new SourceError('sourceFailed'); + let answers=[],partial=false,hasMore=false; + try{const a=await c.get('answers','questions/'+id+'/answers',{site:'stackoverflow',filter:'withbody',pagesize:'10',sort:'votes',order:'desc'});if(a.items.length>10)throw new SourceError('sourceFailed');answers=a.items.map(item=>snapshotAnswer(item,id));hasMore=a.has_more;} + catch(e){if(e.name==='AbortError')throw e;partial=true;} + const source={schema:1,tool:'GapCueStackOverflow',source:'Stack Overflow',captured_at:new Date().toISOString(),question,answers,answers_has_more:hasMore,partial};validate(source);return source; + } + function validate(s){ + if(!s||s.schema!==1||s.tool!=='GapCueStackOverflow'||s.source!=='Stack Overflow'||!text(s.captured_at,100)||!Number.isFinite(Date.parse(s.captured_at))||(s.search_query!==undefined&&!text(s.search_query,120))||!s.question||!isId(s.question.id)||s.question.url!=='https://stackoverflow.com/questions/'+s.question.id||!text(s.question.title,1000)||!text(s.question.created_at,100)||!isInt(s.question.answer_count)||!Array.isArray(s.answers)||s.answers.length>10||typeof s.partial!=='boolean'||typeof s.answers_has_more!=='boolean')throw new SourceError('invalidSnapshot'); + function check(v){if(!v||!v.body||!text(v.body.text,16000)||!isInt(v.body.characters)||[...v.body.text].length>v.body.characters||typeof v.body.truncated!=='boolean'||!v.attribution||!v.attribution.author||!text(v.attribution.author.name,200)||(v.attribution.author.url!==null&&personUrl(v.attribution.author.url)!==v.attribution.author.url)||!text(v.attribution.license,80)||!v.attribution.license||!Array.isArray(v.repositories)||v.repositories.length>10||v.repositories.some(u=>repoUrl(u)!==u))throw new SourceError('invalidSnapshot');} + check(s.question);const ids=new Set();for(const a of s.answers){if(!isId(a.id)||ids.has(a.id)||a.url!=='https://stackoverflow.com/a/'+a.id||!text(a.created_at,100)||typeof a.accepted!=='boolean')throw new SourceError('invalidSnapshot');ids.add(a.id);check(a);}return s; + } + const api={SourceError,questionUrl,personUrl,repoUrl,repos,plain,excerpt,author,hash,throttle,memoryStore,client,search,collect,validate,licenses};return api; +})); diff --git a/app/stackoverflow.css b/app/stackoverflow.css new file mode 100644 index 0000000..5bd50f8 --- /dev/null +++ b/app/stackoverflow.css @@ -0,0 +1 @@ +.stack-intro p{margin-top:10px}.stack-desk{display:grid;grid-template-columns:minmax(0,1fr) 290px;gap:25px;align-items:start}.stack-side{display:flex;flex-direction:column;gap:20px}.stack-desk .reading{min-height:0}.stack-desk .reading .source-excerpt{white-space:pre-wrap;overflow-wrap:anywhere}.stack-desk .comments-title{margin-top:30px}.stack-credit{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--muted);margin:8px 0}.stack-repos{margin:10px 0 20px}.stack-repo{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:8px 0}.stack-repo button{padding:5px 9px;font-size:12px}.stack-answer{padding:18px 0;border-top:1px solid var(--line)}.stack-answer h4{margin:0}.stack-results-item{padding:10px 0;border-bottom:1px solid var(--line)}.stack-results-item .hint{margin:3px 0}.stack-status-error{color:#9c273c}.stack-search-link{margin-top:9px}@media(max-width:760px){.stack-desk{display:flex;flex-direction:column}.stack-side{position:static;width:100%}.stack-desk .reading{width:100%}} diff --git a/app/stackoverflow.html b/app/stackoverflow.html new file mode 100644 index 0000000..c8a5d7a --- /dev/null +++ b/app/stackoverflow.html @@ -0,0 +1,43 @@ + + + + + + + + Stack Overflow research · GapCue + + + + + + + + +
+ GapCueProject research desk + +
+
+

Find the question behind the project.

Search Stack Overflow, read the question and answers, then follow project links.

+
+ +
+

Search results come from Stack Overflow.

+
+ + +
Question and answer content from Stack Overflow, with each author's license shown. Search terms are sent to Stack Exchange only when you search.
+
+ + diff --git a/app/styles.css b/app/styles.css index 15667a6..57dc5bc 100644 --- a/app/styles.css +++ b/app/styles.css @@ -4,6 +4,7 @@ @media(max-width:980px){.desk{grid-template-columns:190px minmax(0,1fr)}.margin{position:static;grid-column:2;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px}.index{grid-row:span 2}.collect-guide{gap:26px}.reading{min-height:0}} @media(max-width:680px){.masthead{padding:20px;align-items:flex-start;flex-direction:column;gap:17px}.brand{font-size:26px;flex-shrink:0}.brand small{white-space:nowrap}.masthead nav{width:100%;gap:22px;justify-content:flex-start}.masthead nav button{margin-left:auto;padding:7px 0}.masthead nav a{white-space:nowrap}main{padding:0 20px 24px}.intro{padding-top:17px}.intro h1{font-size:33px}.intro p{font-size:14px}.open-actions{width:100%;flex-wrap:wrap}.snapshot{margin-bottom:19px;gap:5px 12px}.desk{display:flex;flex-direction:column;gap:18px}.index{position:static;width:100%}.issue-list{max-height:250px;overflow:auto}.index-note{display:none}.issue-item{padding:12px}.issue-item .item-id{margin-bottom:5px}.issue-item .item-coverage{margin-top:6px}.reading{width:100%;padding:20px 17px}.issue-title{font-size:23px}.thread-heading{align-items:flex-start}.comments-title{flex-direction:column;gap:4px}.margin{width:100%;display:flex;flex-direction:column;gap:10px}.references{width:100%}.decision{width:100%;padding:20px}.collect-guide{display:block;margin-top:28px}.collect-guide details{margin-top:22px}.source-excerpt{font-size:16px}.source-heading{align-items:center}.comment-heading{align-items:flex-start}footer{display:block}footer a{display:inline-block;margin-top:12px}.collect-guide pre{font-size:11px}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} +@media(max-width:680px){.masthead nav{flex-wrap:wrap;gap:12px 20px}} .research{border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin:0 0 20px;background:var(--paper)}.research>label{display:block;font-size:13px;font-weight:650;margin-bottom:8px}.research-controls{display:flex;align-items:end;gap:10px}.research-controls>input{width:100%;min-width:0;flex:1;padding:11px 13px;border:1px solid #bdb4c8;border-radius:7px;background:#fff}.sort-control{display:flex;flex-direction:column;flex-shrink:0;font-size:10px;color:var(--muted);gap:3px}.sort-control select{padding:9px;border:1px solid #bdb4c8;border-radius:7px;background:#fff}.research-controls>button{white-space:nowrap}.research .hint{margin-bottom:0;min-height:18px}.discover{margin:0 0 24px;border-left:3px solid #b5a3cd;padding-left:18px}.discover h2{margin-bottom:7px}.search-result{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:16px;padding:8px 0;border-bottom:1px solid #e9e4ef}.result-open{grid-column:1;text-align:left;justify-self:start;border:0;background:transparent;font-size:14px;font-weight:600;padding:4px 0;color:var(--blue)}.search-result>.hint{grid-column:1;margin:0}.search-result>a{grid-row:1/3;grid-column:2;align-self:center;max-width:24ch;overflow-wrap:anywhere;white-space:normal}.intro h1{font-size:30px;margin-bottom:0}.intro{padding-top:12px;padding-bottom:20px}.badge.discussion{color:#3558a6;background:#edf1fa} @media(max-width:680px){.research{padding:15px}.research-controls{flex-wrap:wrap;align-items:stretch}.research-controls>input{flex-basis:100%}.sort-control{flex:1}.research-controls>button{align-self:end}.search-result{display:flex;flex-direction:column;gap:3px}.search-result>a{align-self:start}.intro h1{font-size:29px}} diff --git a/docs/FORMAT.md b/docs/FORMAT.md index ce473e8..4372589 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -58,3 +58,11 @@ When an HN search starts from a GitHub or AgentAtlas repository result, the sele The version-1 review wrapper supports either report version in its exact `source_text` and SHA-256. Existing version-1 reports/reviews remain readable. There is no migration that rewrites an old source snapshot. Primary endpoints: [HN Search API](https://hn.algolia.com/api), [official HN item API](https://github.com/HackerNews/API#items). + +## Stack Overflow browser review + +The Stack Overflow page uses a separate `GapCueStackOverflowReview` file with `schema: 1`, an exact JSON `source_text`, its SHA-256, and the user's verdict and notes. Open it only in `app/stackoverflow.html`; it is not a `GapCueReview`, CLI report, or HN report. The source snapshot holds one question and at most 10 vote-sorted answers. It includes the post author and profile link, original post link, reported content license and a shortened plain-text excerpt (up to 6,000 Unicode code points). HTML formatting is flattened; link destinations in the text are kept inline. This is a bounded sample, not a full discussion or demand measurement. + +The browser search uses a safe Stack Exchange API filter that adds the per-question content-license field without fetching post bodies. Search results show the title, author/profile, license, and original question link. Selecting a result reads the question and answers from the API. Requests are anonymous GETs, limited to 1 MiB and 20 seconds, with no automatic retry; the app observes API backoff and a 60-second same-query window. It stores only hashed request fingerprints and pause timestamps in local browser storage. It does not upload searches, reports, or notes. Current content can change; the snapshot records the time it was read. + +Primary references: [Stack Exchange API terms](https://stackoverflow.com/legal/api-terms-of-use), [content license guidance](https://stackoverflow.com/help/licensing), [API question search](https://api.stackexchange.com/docs/advanced-search), and [API filters](https://api.stackexchange.com/docs/filters). diff --git a/package.json b/package.json index b96b2a3..bd3f33e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gapcue", "version": "0.6.2", "private": true, - "description": "Browser research desk for public GitHub issues and Hacker News discussions.", + "description": "Browser research desk for public GitHub, Hacker News, and Stack Overflow sources.", "license": "MIT", "scripts": { "start": "node scripts/serve.cjs", @@ -15,7 +15,8 @@ "test:atlas": "node tests/atlas-browser.cjs", "test:provenance": "node tests/repo-hn-provenance.cjs", "test:hn-repo": "node tests/hn-linked-repo.cjs", - "test:hn-find-repos": "node tests/hn-find-repos.cjs" + "test:hn-find-repos": "node tests/hn-find-repos.cjs", + "test:stackoverflow": "node tests/stackoverflow-browser.cjs" }, "devDependencies": { "playwright": "1.64.1" diff --git a/scripts/serve.cjs b/scripts/serve.cjs index a16c248..29d00af 100644 --- a/scripts/serve.cjs +++ b/scripts/serve.cjs @@ -8,6 +8,10 @@ files.set('/compare.html', ['compare.html','text/html']); files.set('/compare.css', ['compare.css','text/css']); files.set('/compare.js', ['compare.js','text/javascript']); files.set('/compare-core.js', ['compare-core.js','text/javascript']); +files.set('/stackoverflow.html', ['stackoverflow.html','text/html']); +files.set('/stackoverflow.css', ['stackoverflow.css','text/css']); +files.set('/stackoverflow-core.js', ['stackoverflow-core.js','text/javascript']); +files.set('/stackoverflow-app.js', ['stackoverflow-app.js','text/javascript']); function createServer() { return http.createServer(async(req,res)=>{ const url = new URL(req.url, 'http://127.0.0.1'); diff --git a/tests/stackoverflow-browser.cjs b/tests/stackoverflow-browser.cjs new file mode 100644 index 0000000..5f4b31b --- /dev/null +++ b/tests/stackoverflow-browser.cjs @@ -0,0 +1,38 @@ +const assert=require('node:assert/strict'); +const fs=require('node:fs/promises'); +const os=require('node:os'); +const path=require('node:path'); +const {chromium}=require(process.env.GAPCUE_PLAYWRIGHT||'playwright'); +const {createServer}=require('../scripts/serve.cjs'); +const fixtureQ={question_id:101,title:'Synthetic agent question',link:'https://stackoverflow.com/questions/101/example',creation_date:1780000000,answer_count:1,body:'A <script>window.injected=true</script> project',owner:{display_name:'Original author',link:'https://stackoverflow.com/users/5/person'},content_license:'CC BY-SA 4.0'}; +const fixtureA={answer_id:202,question_id:101,creation_date:1780000001,body:'A synthetic answer.',owner:{display_name:'Answer author',link:'https://stackoverflow.com/users/6/person'},content_license:'CC BY-SA 3.0',is_accepted:true}; +(async()=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'gapcue-so-browser-')); + const server=createServer();await new Promise(r=>server.listen(0,'127.0.0.1',r));const url='http://127.0.0.1:'+server.address().port; + const browser=await chromium.launch({channel:process.env.BROWSER_CHANNEL||'chrome',headless:true}); + const ctx=await browser.newContext({acceptDownloads:true,viewport:{width:1440,height:900}}),page=await ctx.newPage(),apiRequests=[],errors=[]; + page.on('pageerror',e=>errors.push(e.message)); + await ctx.route('https://api.stackexchange.com/**',async route=>{ + const req=route.request(),u=new URL(req.url());apiRequests.push(req.url());assert.equal(req.method(),'GET');assert.equal(req.headers().authorization,undefined);assert.equal(req.headers().cookie,undefined);assert.equal(req.headers().referer,undefined); + const body=u.pathname.includes('search/advanced')?{items:[fixtureQ],has_more:false}:u.pathname.includes('/answers')?{items:[fixtureA],has_more:false}:{items:[fixtureQ],has_more:false}; + await route.fulfill({status:200,headers:{'access-control-allow-origin':'*'},contentType:'application/json',body:JSON.stringify(body)}); + }); + await ctx.route('https://api.github.com/repos/example/fixture',route=>route.fulfill({status:200,headers:{'access-control-allow-origin':'*'},contentType:'application/json',body:JSON.stringify({private:false,full_name:'example/fixture',html_url:'https://github.com/example/fixture',stargazers_count:17,created_at:'2026-01-01T00:00:00Z',archived:false,fork:false})})); + try{ + await page.goto(url+'/stackoverflow.html');assert.equal(apiRequests.length,0); + await page.locator('#query').fill('agent workflow');await page.locator('#search').click();await page.getByRole('button',{name:'Synthetic agent question'}).waitFor();assert.equal(apiRequests.length,1); + await page.getByRole('button',{name:'Synthetic agent question'}).click();await page.locator('#questionTitle').getByText('Synthetic agent question').waitFor();assert.equal(apiRequests.length,3); + assert.match(await page.locator('#questionCredit').textContent(),/Original author/);assert.match(await page.locator('#questionCredit').textContent(),/CC BY-SA 4.0/);assert.match(await page.locator('#answers').textContent(),/Answer author/);assert.match(await page.locator('#answers').textContent(),/CC BY-SA 3.0/);assert.equal(await page.evaluate(()=>window.injected),undefined); + assert.equal(await page.getByRole('link',{name:'example/fixture'}).getAttribute('href'),'https://github.com/example/fixture');await page.getByRole('button',{name:'Check current Stars'}).click();await page.getByText(/17 Stars/).waitFor(); + await page.locator('#notes').fill('Compare the existing tool before building.');await page.locator('#verdict').selectOption('followup'); + const download=page.waitForEvent('download');await page.locator('#save').click();const saved=path.join(temp,'review.json');await (await download).saveAs(saved); + const bundle=JSON.parse(await fs.readFile(saved,'utf8'));assert.equal(bundle.verdict,'followup');assert.match(bundle.notes,/Compare/);assert.equal(JSON.parse(bundle.source_text).answers[0].attribution.license,'CC BY-SA 3.0'); + const prior=apiRequests.length;await page.locator('#search').click();await page.getByText(/requested recently/).waitFor();assert.equal(apiRequests.length,prior); + await page.close();const restored=await ctx.newPage(); + // Open the local viewer, then disconnect before importing the saved file. + await restored.goto('file://'+path.resolve('app/stackoverflow.html'));await ctx.setOffline(true);await restored.locator('#file').setInputFiles(saved);await restored.getByText('Saved research opened.').waitFor();assert.equal(await restored.locator('#notes').inputValue(),'Compare the existing tool before building.');assert.equal(await restored.locator('#verdict').inputValue(),'followup');assert.equal(apiRequests.length,prior); + await restored.locator('#language').click();await restored.setViewportSize({width:390,height:844});assert.equal(await restored.locator('h1').textContent(),'从问题出发,寻找项目。');assert.equal(await restored.locator('#saveStatus').textContent(),'已打开保存的调研。');assert.ok(await restored.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await restored.screenshot({path:path.join(temp,'mobile-zh.png'),fullPage:true}); + const bad={...bundle,source_text:bundle.source_text+' '};const badfile=path.join(temp,'tampered.json');await fs.writeFile(badfile,JSON.stringify(bad));await restored.locator('#file').setInputFiles(badfile);await restored.getByText('无法打开此调研文件。').waitFor();assert.equal(await restored.locator('#notes').inputValue(),'Compare the existing tool before building.'); + assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'passed',browser:browser.version(),api_requests:apiRequests.length,checks:['anonymous search','attributed question and answer','inert source text','GitHub star on demand','saved review','duplicate request blocked','offline restore','Chinese mobile','tamper rejected'],screenshot:path.join(temp,'mobile-zh.png')},null,2)); + }finally{await ctx.close();await browser.close();await new Promise(r=>server.close(r));} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/stackoverflow.test.cjs b/tests/stackoverflow.test.cjs new file mode 100644 index 0000000..8c54a51 --- /dev/null +++ b/tests/stackoverflow.test.cjs @@ -0,0 +1,43 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const so=require('../app/stackoverflow-core.js'); +const now=1790000000000; +const q={question_id:101,title:'How & why?',link:'https://stackoverflow.com/questions/101/how-why',creation_date:1780000000,answer_count:1,body:'Read the project. <img src=x>',owner:{display_name:'A & B',link:'https://stackoverflow.com/users/5/person'},content_license:'CC BY-SA 4.0'}; +const answer={answer_id:202,question_id:101,creation_date:1780000001,body:'See another.',owner:{display_name:'Responder',link:'https://stackoverflow.com/users/6/responder'},content_license:'CC BY-SA 3.0',is_accepted:false}; +function fixture(fn,calls=[]){return async(url,options)=>{calls.push({url,options});const v=fn(url);return new Response(JSON.stringify(v.body??v),{status:v.status||200,headers:{'content-type':'application/json'}});};} +function fake(url){if(url.includes('search/advanced'))return {items:[q],has_more:true};if(url.includes('/answers?'))return {items:[answer],has_more:false};return {items:[q],has_more:false};} +function options(fetcher,storage=so.memoryStore(),clock=()=>now){return {fetcher,storage,now:clock,locks:null};} +test('only canonical source and repository URLs survive',()=>{ + assert.equal(so.questionUrl('https://stackoverflow.com/questions/101/how-why?x=1'),'https://stackoverflow.com/questions/101'); + for(const bad of ['https://stackoverflow.com.evil.test/questions/101','http://stackoverflow.com/questions/101','https://u:p@stackoverflow.com/questions/101','https://stackoverflow.com/questions/0'])assert.equal(so.questionUrl(bad),null); + assert.equal(so.repoUrl('https://github.com/example/repo/issues/1'),'https://github.com/example/repo'); + assert.equal(so.repoUrl('https://github.com.evil.test/example/repo'),null); + assert.deepEqual(so.repos(q.body),['https://github.com/example/fixture']); +}); +test('search is bounded, anonymous, and exposes no fabricated total or solved claim',async()=>{ + const calls=[];const out=await so.search(' browser agent ',90,options(fixture(fake,calls))); + assert.equal(out.query,'browser agent');assert.equal(out.has_more,true);assert.equal(out.entries[0].attribution.author.name,'A & B');assert.equal(out.entries[0].attribution.license,'CC BY-SA 4.0');assert.equal(out.entries[0].url,'https://stackoverflow.com/questions/101');assert.equal(out.reported_total,undefined); + assert.equal(calls.length,1);assert.equal(calls[0].options.method,'GET');assert.equal(calls[0].options.credentials,'omit');assert.equal(calls[0].options.referrerPolicy,'no-referrer');assert.equal(new URL(calls[0].url).searchParams.get('pagesize'),'8'); +}); +test('collect preserves each author, license, source, answers, and repo leads',async()=>{ + const calls=[],s=await so.collect(101,options(fixture(fake,calls)));assert.equal(calls.length,2);assert.equal(s.question.attribution.author.name,'A & B');assert.equal(s.question.attribution.license,'CC BY-SA 4.0');assert.equal(s.answers[0].attribution.license,'CC BY-SA 3.0');assert.equal(s.answers[0].accepted,false);assert.equal(s.answers[0].url,'https://stackoverflow.com/a/202');assert.deepEqual(s.question.repositories,['https://github.com/example/fixture']);assert.match(s.question.body.text,//);assert.equal(s.partial,false);assert.equal(so.validate(JSON.parse(JSON.stringify(s))).question.id,101); +}); +test('answer failure keeps the question and marks a partial sample',async()=>{ + const calls=[],s=await so.collect(101,options(fixture(url=>url.includes('/answers?')?{status:503,body:{error_id:500}}:fake(url),calls)));assert.equal(s.partial,true);assert.equal(s.answers.length,0);assert.equal(s.question.id,101);assert.equal(calls.length,2); +}); +test('same semantic request is blocked for a minute across client instances',async()=>{ + let clock=now;const store=so.memoryStore(),calls=[],opts=options(fixture(fake,calls),store,()=>clock); + await so.search('browser agent',0,opts);await assert.rejects(()=>so.search('browser agent',0,opts),e=>e.code==='duplicate');assert.equal(calls.length,1);clock+=60001;await so.search('browser agent',0,opts);assert.equal(calls.length,2); +}); +test('server backoff is stored per method, including on an error; no automatic retry',async()=>{ + let clock=now;const store=so.memoryStore(),calls=[];const client=so.client(options(fixture(()=>({status:503,body:{backoff:30,error_id:500}}),calls),store,()=>clock)); + await assert.rejects(()=>client.get('search','search/advanced',{site:'stackoverflow',q:'one'}),e=>e.code==='sourceFailed');await assert.rejects(()=>client.get('search','search/advanced',{site:'stackoverflow',q:'another'}),e=>e.code==='backoff'&&e.wait===30);assert.equal(calls.length,1);clock+=30001; + await assert.rejects(()=>client.get('search','search/advanced',{site:'stackoverflow',q:'another'}),e=>e.code==='sourceFailed');assert.equal(calls.length,2); +}); +test('missing license or altered source identity is refused',async()=>{ + await assert.rejects(()=>so.collect(101,options(fixture(url=>({...fake(url),items:[{...q,content_license:undefined}]})))),e=>e.code==='licenseUnavailable'); + const s=await so.collect(101,options(fixture(fake)));s.question.url='https://stackoverflow.com.evil.test/questions/101';assert.throws(()=>so.validate(s),e=>e.code==='invalidSnapshot'); +}); +test('deleted author remains attributed as unavailable without an invented profile',async()=>{ + const s=await so.collect(101,options(fixture(url=>url.includes('/answers?')?{items:[{...answer,owner:{}}],has_more:false}:fake(url))));assert.deepEqual(s.answers[0].attribution.author,{name:'[deleted or unavailable]',url:null}); +});