feat: 片名输入加下拉联想,不必把片名打全就能选片

输入片名时实时下拉候选(海报缩略图 + 片名 + 原名 + 年份 + 电影/剧集标注),
选中即自动填充影片信息,省去「打全片名再点按钮」这一步。

fetch_movie.php
- 新增 action=suggest:只回候选列表,不取详情、不下载海报、不写任何文件,
  可随输入安全地频繁调用;会过滤「影人」类条目、按豆瓣条目 id 去重、默认限 8 条。
- 新增 action=thumb:服务端代取豆瓣缩略图。豆瓣图片有防盗链(只有 Referer 为
  豆瓣域名才返回 200,其它一律 403),浏览器直连必然被拒,只能由服务端带
  Referer 代取;并加 Cache-Control 让浏览器缓存,重复展开不重复回源。
- 新增 ?id=豆瓣条目id:跳过搜索直接取详情。下拉选中后走这条路,既省一次搜索,
  也避免「再按片名搜一次、结果选错条目」。
- 安全:新增 validateDoubanImageUrl() 严格白名单(只允许
  https://img*.doubanio.com/view/photo/…),否则 thumb 就成了 SSRF 跳板;
  接口地址的测试用覆盖项一并写进注释。

add.php
- 片名输入框改为「输入即联想」:320ms 防抖、会话内结果缓存、用 AbortController
  丢弃过期在途请求、↑↓ 选择 / Enter 确认 / Esc 关闭、点击外部关闭、
  中文输入法组字期间不发请求也不劫持 Enter(此时 Enter 是选字)。
- 候选里的豆瓣文本一律转义后再插入,避免把对方内容当 HTML 执行。
- 「📡 自动获取」按钮保留:候选里没有想要的条目时仍可按回车或点按钮,由服务端
  挑最匹配的条目;按钮与下拉选中复用同一套获取逻辑(runFetch)。

README.md
- 接口文档改为四种模式(q / id / suggest / thumb),补上参数、响应样例、
  防盗链原因与白名单说明;使用说明与安全注意事项同步更新。

验证
- fetch_movie_test.py 端到端 79 项全通过(新增 suggest / thumb / id 三组用例,
  含「suggest 与 thumb 不写任何文件」「非白名单地址一律 400」)。
- storage_test.py 回归 196 项全通过。
- A/B 渲染比对 47 个响应:仅 add.php 的两个页面有差异,且差异全部落在下拉联想
  相关代码上,其余响应逐字节一致。
- 已部署到线上,双 PHP 版本(7.4 / 8.1)真实抓取通过:suggest 返回候选、
  thumb 代取回 27KB JPEG、非白名单地址被拒、id= 直连取详情成功。
This commit is contained in:
2026-09-22 07:36:05 +08:00
parent ddf2eeafaa
commit 99c22d17bd
3 changed files with 522 additions and 81 deletions
+271 -50
View File
@@ -315,11 +315,64 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
gap: 12px;
justify-content: flex-end;
}
/* ---- 片名下拉联想 ---- */
.ac-wrap { flex: 1; position: relative; min-width: 0; }
.ac-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 60;
margin-top: 4px;
padding: 4px;
max-height: 328px;
overflow-y: auto;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow);
}
.ac-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
}
.ac-item:hover, .ac-item.active { background: rgba(127, 127, 127, 0.14); }
.ac-thumb {
flex: 0 0 auto;
width: 38px;
height: 54px;
object-fit: cover;
border-radius: 4px;
background: rgba(127, 127, 127, 0.15);
}
.ac-meta { min-width: 0; }
.ac-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ac-sub {
margin-top: 2px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ac-hint { padding: 8px; font-size: 13px; color: var(--text-secondary); }
@media (max-width: 600px) {
.form-card { padding: 20px 16px; }
.form-row { grid-template-columns: 1fr; }
.form-actions { flex-direction: column; }
.form-actions .btn { width: 100%; text-align: center; }
.ac-list { max-height: 240px; }
}
</style>
</head>
@@ -337,11 +390,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<?php endif; ?>
<form method="POST" action="" enctype="multipart/form-data" id="addForm">
<!-- 电影名称 + 自动获取按钮 -->
<!-- 电影名称 + 下拉联想 + 自动获取按钮 -->
<div class="form-group">
<label>电影名称 <span class="hint">*必填</span></label>
<div style="display: flex; gap: 10px;">
<input type="text" name="title" id="movieTitle" value="<?= h($_POST['title'] ?? '') ?>" placeholder="请输入电影名称" required style="flex:1;">
<label>电影名称 <span class="hint">*必填(输入几个字即可,从下拉里选)</span></label>
<div style="display: flex; gap: 10px; align-items: flex-start;">
<div class="ac-wrap">
<input type="text" name="title" id="movieTitle"
value="<?= h($_POST['title'] ?? '') ?>"
placeholder="输入片名(中文即可),下方会给出候选"
required autocomplete="off"
role="combobox" aria-expanded="false"
aria-controls="titleSuggest" aria-autocomplete="list">
<div id="titleSuggest" class="ac-list" role="listbox" hidden></div>
</div>
<button type="button" class="btn btn-primary" id="fetchBtn" style="white-space:nowrap;">📡 自动获取</button>
</div>
<div id="fetchStatus" style="font-size:13px;color:var(--text-secondary);margin-top:4px;"></div>
@@ -580,39 +641,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
});
// ---------- 豆瓣自动获取 ----------
document.getElementById('fetchBtn').addEventListener('click', function() {
const title = document.getElementById('movieTitle').value.trim();
const status = document.getElementById('fetchStatus');
const btn = this;
// ---------- 豆瓣自动获取(「按钮」与「下拉选中」共用同一套逻辑)----------
const movieTitleEl = document.getElementById('movieTitle');
const movieInfoEl = document.getElementById('movieInfo');
const fetchBtnEl = document.getElementById('fetchBtn');
const fetchStatusEl = document.getElementById('fetchStatus');
const fetchedKeyEl = document.getElementById('fetchedPosterKey');
const posterPreviewEl = document.getElementById('posterPreview');
const posterWrapEl = document.getElementById('posterPreviewWrap');
const posterHintEl = document.getElementById('posterPreviewHint');
if (!title) {
status.style.color = '#e74c3c';
status.textContent = '⚠️ 请先输入电影名称';
return;
}
// 豆瓣返回的文本一律先转义再插入,避免把对方内容当 HTML 执行
function acEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
btn.disabled = true;
status.style.color = 'var(--text-secondary)';
status.textContent = '⏳ 正在获取…';
function setFetchStatus(text, color) {
fetchStatusEl.style.color = color || 'var(--text-secondary)';
fetchStatusEl.textContent = text;
}
// query 形如 'q=片名' 或 'id=豆瓣条目id'
function runFetch(query) {
fetchBtnEl.disabled = true;
setFetchStatus('⏳ 正在获取…');
// 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
const oldKey = document.getElementById('fetchedPosterKey').value;
let url = 'fetch_movie.php?q=' + encodeURIComponent(title);
let url = 'fetch_movie.php?' + query;
const oldKey = fetchedKeyEl.value;
if (oldKey) url += '&old_poster=' + encodeURIComponent(oldKey);
fetch(url)
return fetch(url)
.then(res => res.json())
.then(data => {
if (data.error) {
status.style.color = '#e74c3c';
status.textContent = '❌ ' + data.error;
btn.disabled = false;
setFetchStatus('❌ ' + data.error, '#e74c3c');
return;
}
// 片名以豆瓣的正名称为准(自动补全/纠正)
if (data.title) document.getElementById('movieTitle').value = data.title;
if (data.title) movieTitleEl.value = data.title;
// 组装电影信息文本
let infoText = '';
@@ -625,44 +695,195 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (data.rating) infoText += '⭐ 豆瓣评分:' + data.rating + '\n';
if (data.alias) infoText += '🔖 又名:' + data.alias + '\n';
if (data.plot) infoText += '\n📝 ' + data.plot;
document.getElementById('movieInfo').value = infoText;
movieInfoEl.value = infoText;
// 海报:已由服务端下载入库。这里记录键值(提交时写入 poster_path)并预览。
const keyInput = document.getElementById('fetchedPosterKey');
const preview = document.getElementById('posterPreview');
const wrap = document.getElementById('posterPreviewWrap');
const hint = document.getElementById('posterPreviewHint');
if (data.poster && data.poster_key) {
keyInput.value = data.poster_key;
preview.src = data.poster;
hint.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
wrap.style.display = 'block';
status.textContent = '✅ 已获取信息与海报';
fetchedKeyEl.value = data.poster_key;
posterPreviewEl.src = data.poster;
posterHintEl.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
posterWrapEl.style.display = 'block';
setFetchStatus('✅ 已获取信息与海报', '#27ae60');
} else {
keyInput.value = '';
wrap.style.display = 'none';
status.textContent = '✅ 已获取基本信息(该片暂无海报)';
fetchedKeyEl.value = '';
posterWrapEl.style.display = 'none';
setFetchStatus('✅ 已获取基本信息(该片暂无海报)', '#27ae60');
}
status.style.color = '#27ae60';
btn.disabled = false;
})
.catch(err => {
status.style.color = '#e74c3c';
status.textContent = '❌ 网络错误,请稍后重试';
btn.disabled = false;
setFetchStatus('❌ 网络错误,请稍后重试', '#e74c3c');
console.error(err);
})
.finally(function () {
fetchBtnEl.disabled = false;
});
}
fetchBtnEl.addEventListener('click', function () {
const title = movieTitleEl.value.trim();
if (!title) {
setFetchStatus('⚠️ 请先输入电影名称', '#e74c3c');
return;
}
runFetch('q=' + encodeURIComponent(title));
});
// 回车触发获取
document.getElementById('movieTitle').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('fetchBtn').click();
// ---------- 片名下拉联想 ----------
// 输入几个字 → 服务端只做一次搜索(不取详情、不下载海报)→ 下拉展示候选。
// 选中后按豆瓣条目 id 直达详情,省一次搜索,也不会「重新搜一次选错条目」。
(function () {
const listEl = document.getElementById('titleSuggest');
if (!listEl) return;
const CACHE = new Map(); // 关键词 -> items,同一会话内不重复请求
const DEBOUNCE = 320;
let timer = null;
let ctrl = null; // 用于丢弃过期的在途请求
let items = [];
let active = -1;
let composing = false; // 中文输入法组字中
function closeList() {
listEl.hidden = true;
listEl.innerHTML = '';
movieTitleEl.setAttribute('aria-expanded', 'false');
items = [];
active = -1;
}
});
function render(rows, hintText) {
if (!rows.length) {
if (!hintText) { closeList(); return; }
listEl.innerHTML = '<div class="ac-hint">' + acEsc(hintText) + '</div>';
listEl.hidden = false;
movieTitleEl.setAttribute('aria-expanded', 'true');
items = [];
active = -1;
return;
}
items = rows;
active = -1;
listEl.innerHTML = rows.map(function (r, i) {
const thumb = r.thumb
? '<img class="ac-thumb" src="' + acEsc(r.thumb) + '" alt="" loading="lazy">'
: '<div class="ac-thumb"></div>';
// 副标题:原名(与正名不同才显示)、年份、电影/剧集、集数
const sub = [
(r.sub_title && r.sub_title !== r.title) ? r.sub_title : '',
r.year,
r.kind,
(parseInt(r.episode, 10) > 1) ? (r.episode + ' 集') : ''
].filter(Boolean).join(' · ');
return '<div class="ac-item" role="option" data-i="' + i + '">' + thumb +
'<div class="ac-meta">' +
'<div class="ac-title">' + acEsc(r.title) + '</div>' +
'<div class="ac-sub">' + acEsc(sub) + '</div>' +
'</div></div>';
}).join('');
listEl.hidden = false;
movieTitleEl.setAttribute('aria-expanded', 'true');
}
function highlight() {
Array.prototype.forEach.call(listEl.querySelectorAll('.ac-item'), function (el, i) {
el.classList.toggle('active', i === active);
});
const cur = listEl.querySelector('.ac-item.active');
if (cur) cur.scrollIntoView({ block: 'nearest' });
}
function move(delta) {
if (!items.length) return;
active = (active + delta + items.length) % items.length;
highlight();
}
function choose(i) {
const it = items[i];
if (!it) return;
closeList();
movieTitleEl.value = it.title;
runFetch('id=' + encodeURIComponent(it.id));
}
function request(kw) {
if (CACHE.has(kw)) {
render(CACHE.get(kw));
return;
}
if (ctrl) ctrl.abort(); // 丢掉上一次在途请求
ctrl = new AbortController();
const mine = ctrl;
render([], '⏳ 搜索中…');
fetch('fetch_movie.php?action=suggest&q=' + encodeURIComponent(kw),
{ signal: mine.signal })
.then(res => res.json())
.then(data => {
if (movieTitleEl.value.trim() !== kw) return; // 期间又输入了,结果作废
const rows = (data && data.items) ? data.items : [];
CACHE.set(kw, rows);
render(rows, '没有找到相关影视,试试补充更完整的片名');
})
.catch(err => {
if (err && err.name === 'AbortError') return;
render([], '联想暂时不可用,可直接点「📡 自动获取」');
});
}
function schedule() {
const kw = movieTitleEl.value.trim();
clearTimeout(timer);
if (!kw) { closeList(); return; }
timer = setTimeout(function () { request(kw); }, DEBOUNCE);
}
// 中文输入法:组字期间不请求(拼音串没意义),也不劫持 Enter(此时 Enter 是选字)
movieTitleEl.addEventListener('compositionstart', function () { composing = true; });
movieTitleEl.addEventListener('compositionend', function () {
composing = false;
schedule();
});
movieTitleEl.addEventListener('input', function () {
if (!composing) schedule();
});
movieTitleEl.addEventListener('keydown', function (e) {
if (e.isComposing || composing) return; // 交给输入法处理
if (!listEl.hidden && items.length) {
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); return; }
if (e.key === 'Enter') {
e.preventDefault();
choose(active >= 0 ? active : 0);
return;
}
}
if (e.key === 'Escape') { closeList(); return; }
if (e.key === 'Enter') {
e.preventDefault();
closeList();
fetchBtnEl.click(); // 无候选时,Enter 等价于点「自动获取」
}
});
// 用 mousedown + preventDefault:否则会先失焦关掉列表,导致点不中
listEl.addEventListener('mousedown', function (e) {
const el = e.target.closest ? e.target.closest('.ac-item') : null;
if (!el) return;
e.preventDefault();
choose(parseInt(el.dataset.i, 10));
});
// 点到别处则关闭
document.addEventListener('click', function (e) {
if (listEl.hidden) return;
if (e.target === movieTitleEl || listEl.contains(e.target)) return;
closeList();
});
})();
</script>
</body>
</html>