"""Seesaa 上的两个认人 Wiki(av_neme、av_name):作品条目的精确番号出演候选。 夹具是 2026-09-24 实测页面裁出的骨架(小节、列表、键值表、搜索结果的标签与类名照原样), 人名与作品名换成虚构的。 """ import argparse import csv import importlib.util import json import sqlite3 import tempfile import unittest from pathlib import Path from unittest.mock import Mock, patch from peach.http import HttpResponse from peach.metadata import MetadataProviderError from peach.sources import Page, Session from peach.sources.seesaa import AvNameSource, AvNemeSource, WikiPages NAME = 'https://seesaawiki.jp/av_name/' NEME = 'https://seesaawiki.jp/av_neme/' EDIT = '' def page(inner): return f'
{inner}
'.encode('euc_jp') def section(heading, body, root=NAME): return ('
' f'{heading} {EDIT.format(root=root)}
{body}
') def code_page(code='ABC-001', cast=None): """av_name 的单品番页:一张「品番/配信品番/素人名義/出演女優/…」键值表。""" cast = cast if cast is not None else ( f'架空花子(架空はなこ)
' f'架空春子(かくう はるこ)') return page(f'{code}に出てるAV女優の名前は、架空花子。' '

基本情報

' '
' f'' '' f'' '' f'' '
品番{code}
配信品番abc001
素人名義はなちゃん
出演女優{cast}
配信開始日2026-09-24
レーベル架空レーベル
') def name_entry(code, cast): """av_name 厂牌页上的一部作品:`h5` 品番,列表里「出演女優/タイトル/配信日」。""" return section(code, '' f'') def neme_entry(code, cast, tail=''): """av_neme 系列页、月份页上的一部作品:`h5` 是「品番| 系列名」,正文一行「名前(女優名):」。""" return section(f'{code}| 架空シリーズ', '
' f'名前(女優名):{cast}{tail}
' '架空の作品' f'(レーベル一覧)
別名:はなちゃん 25歳 会社員
', NEME) def search(root, *names): """站内搜索结果页:一条结果一个 `.body`,`h3` 链到页面。""" bodies = ''.join(f'

{name}

' f'

{root}d/{name}

' for name in names) return f'

全文検索

{bodies}
'.encode('euc_jp') def payloads(site, body, url): return [row.payload() for row in site().rows(Page(url, body))] def serve(routes): """按地址回页的假传输;没登记的地址当测试失败。""" def transport(request, timeout, limit): if request.url not in routes: raise AssertionError('unexpected request ' + request.url) return HttpResponse(200, {}, routes[request.url]) return Mock(side_effect=transport) class AvNameTests(unittest.TestCase): def test_code_page_reads_linked_cast_without_readings_and_keeps_details_as_evidence(self): row = payloads(AvNameSource, code_page(), NAME+'d/ABC%2d001')[0] self.assertEqual((row['id'], row['source_url']), ('ABC-001', NAME+'d/ABC%2d001')) self.assertEqual([a['japanese_name'] for a in row['actresses']], ['架空花子', '架空春子']) evidence = row['wiki_evidence'] self.assertTrue(evidence['performers_complete']) self.assertEqual(evidence['performer_links'][0], {'name': '架空花子(架空はなこ)', 'source_url': NAME+'d/A'}) self.assertEqual(evidence['details']['素人名義'], 'はなちゃん') # 只产出演候选:标题与日期只在证据里。 self.assertEqual((row['title'], row['release_date'], row['cover_urls']), ('', '', [])) def test_label_page_rows_split_complete_unidentified_and_pageless_names(self): body = page(name_entry('300ABC-010', f'架空花子') + name_entry('300ABC-011', '--') + name_entry('300ABC-012', f'架空花子・' '架空夏子' f'?')) rows = {row['id']: row for row in payloads(AvNameSource, body, NAME+'d/Label')} self.assertEqual(set(rows), {'300ABC-010', '300ABC-011', '300ABC-012'}) self.assertEqual(rows['300ABC-010']['actresses'], [{'japanese_name': '架空花子'}]) for code, text in (('300ABC-011', '--'), ('300ABC-012', '架空花子 ・ 架空夏子 ?')): with self.subTest(code=code): self.assertEqual(rows[code]['actresses'], []) self.assertFalse(rows[code]['wiki_evidence']['performers_complete']) self.assertEqual(rows[code]['wiki_evidence']['actress_text'], text) self.assertEqual(len(rows[code]['source_warnings']), 2) def test_person_page_history_is_not_a_record(self): body = page(section('300ABC-010', '')) self.assertEqual(payloads(AvNameSource, body, NAME+'d/Person'), []) def test_search_reads_the_code_page_first_and_rejects_substring_hits(self): with tempfile.TemporaryDirectory() as tmp: routes = {NAME+'search?keywords=ABC-001': search(NAME, 'Person', 'ABC%2d001'), NAME+'d/ABC%2d001': code_page(), NAME+'search?keywords=BC-001': search(NAME, 'ABC%2d001')} transport = serve(routes) source, session = AvNameSource(), Session(WikiPages(Path(tmp), config=AvNameSource.DEFAULT, transport=transport, limiter=Mock())) record = source.query('ABC-001', session=session) self.assertEqual((record.source, record.provenance, record.code), ('av_name', 'av_name', 'ABC-001')) # 人物页排在单品番页之后,找到就停,一次没问。 self.assertEqual([call.args[0].url for call in transport.call_args_list], [NAME+'search?keywords=ABC-001', NAME+'d/ABC%2d001']) # 全文检索把 `ABC-001` 当成 `BC-001` 的命中交回来:读过的页里没有精确番号就不算。 with self.assertRaises(MetadataProviderError) as caught: source.query('BC-001', session=session) self.assertEqual((caught.exception.kind, caught.exception.retryable), ('incomplete_search', True)) class AvNemeTests(unittest.TestCase): def test_series_entry_reads_the_code_before_the_bar(self): body = page(neme_entry('300ABC-010', f'架空花子')) row = payloads(AvNemeSource, body, NEME+'d/Series')[0] self.assertEqual((row['id'], row['actresses']), ('300ABC-010', [{'japanese_name': '架空花子'}])) self.assertIn('別名:はなちゃん', row['wiki_evidence']['entry_text']) def test_uncertain_marks_and_broken_links_keep_only_the_raw_text(self): """站方说明:名字旁带「?」或「▲」是不确定;标记写在名字那一段外面也算。""" cases = {'300ABC-011': (f'架空花子', '?'), '300ABC-012': (f'架空花子▲', ''), '300ABC-013': ('>' f'?', '')} body = page(''.join(neme_entry(code, cast, tail) for code, (cast, tail) in cases.items())) rows = payloads(AvNemeSource, body, NEME+'d/2021%c7%af4%b7%ee') self.assertEqual([row['id'] for row in rows], list(cases)) for row in rows: with self.subTest(code=row['id']): self.assertEqual(row['actresses'], []) self.assertFalse(row['wiki_evidence']['performers_complete']) def test_search_does_not_follow_person_page_label_links(self): """全文检索的结果本身就有载着这个番号的系列页与月份页,人物页上的「レーベル一覧」不再跟读。""" person = page(section('架空シリーズ', f'(レーベル一覧)', NEME)) month = page(neme_entry('300ABC-010', f'架空花子') + neme_entry('300ABC-020', f'架空春子')) with tempfile.TemporaryDirectory() as tmp: transport = serve({NEME+'search?keywords=300ABC-010': search(NEME, 'Person', 'Month'), NEME+'d/Person': person, NEME+'d/Month': month}) source, session = AvNemeSource(), Session(WikiPages(Path(tmp), config=AvNemeSource.DEFAULT, transport=transport, limiter=Mock())) self.assertEqual(source.query('300ABC-010', session=session).performers, ({'japanese_name': '架空花子'},)) # 同一张月份页上的别的作品直接从读过的表里取,不再联网。 self.assertEqual(source.query('300ABC-020', session=session).performers, ({'japanese_name': '架空春子'},)) self.assertEqual(transport.call_count, 3) class ScrapeCodesTests(unittest.TestCase): def script(self): path = Path(__file__).resolve().parents[1] / 'scripts/scrape_codes.py' spec = importlib.util.spec_from_file_location('seesaa_name_wikis_scrape_test', path) script = importlib.util.module_from_spec(spec) spec.loader.exec_module(script) return script def test_named_wikis_share_one_host_interval_and_their_own_prefetched_pages(self): script = self.script() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) pages = root/'pages.txt' pages.write_text(NEME+'d/Month\n' + NAME+'d/Label\n', encoding='utf8') args = argparse.Namespace(wiki_pages_file=pages, raw_dir=root, refresh=False, wiki_max_requests=5, secrets_root=root, tools_root=root) adapter = script._build_adapter(args, ('av_neme', 'av_name')) (neme, neme_session), (name, name_session) = adapter.wikis['av_neme'], adapter.wikis['av_name'] self.assertIs(neme_session.transport.limiter, name_session.transport.limiter) self.assertEqual((neme.pages, name.pages), ((NEME+'d/Month',), (NAME+'d/Label',))) self.assertEqual((neme_session.transport.config.name, name_session.transport.max_requests), ('av_neme', 5)) with self.assertRaisesRegex(ValueError, '预取页不属于'): script._build_adapter(args, ('av_neme',)) def test_search_misses_do_not_cool_the_wiki_down_but_transport_failures_do(self): """样本批里 FC2 排在前面、两站都没收录:连着几个「搜索范围内没有」不该把整站冷却掉。""" script = self.script() throttle = script._Throttle(script._health_rows(('av_name',), 'custom')) miss = MetadataProviderError('miss', kind='incomplete_search', retryable=True) for _ in range(script.COOLDOWN_AFTER_FAILURES + 2): throttle.record_failure('av_name', miss) self.assertEqual(throttle.open_members(('av_name',)), ('av_name',)) down = MetadataProviderError('down', kind='unavailable', retryable=True) for _ in range(script.COOLDOWN_AFTER_FAILURES): throttle.record_failure('av_name', down) self.assertEqual(throttle.open_members(('av_name',)), ()) def test_cli_writes_review_candidates_per_wiki_without_touching_the_ledger(self): script = self.script() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp).resolve() db = root/'ledger.db' with sqlite3.connect(db) as c: c.executescript("CREATE TABLE asset(id INTEGER, medium TEXT, code TEXT, size INTEGER, path TEXT, name TEXT, catalog_title TEXT, original_title TEXT, studio TEXT, series TEXT, release_date TEXT); CREATE TABLE entity(id INTEGER, kind TEXT, canonical_name TEXT); CREATE TABLE asset_entity(asset_id INTEGER, entity_id INTEGER, role TEXT); CREATE TABLE genre_decision(source_genre TEXT PRIMARY KEY, raw_genre TEXT NOT NULL, peach_tag TEXT, decided_at TEXT NOT NULL); INSERT INTO asset VALUES(1,'video','ABC-001',1,'one.mp4','one.mp4',NULL,NULL,NULL,NULL,NULL);") c.close() before = db.read_bytes() output = root/'metadata-field-candidates-test.csv' transport = serve({NAME+'search?keywords=ABC-001': search(NAME, 'ABC%2d001'), NAME+'d/ABC%2d001': code_page(), NEME+'search?keywords=ABC-001': search(NEME)}) with patch('peach.sources.seesaa.HttpxTransport', return_value=transport), \ patch('peach.library_processing.LibraryMetadataProvider', side_effect=AssertionError('not required')): self.assertEqual(script.main(['--db', str(db), '--out', str(output), '--raw-dir', str(root/'raw'), '--log-dir', str(root/'logs'), '--sources', 'av_neme,av_name', '--delay', '0', '--min-free', '0']), 0) with output.open(encoding='utf-8-sig') as handle: rows = list(csv.DictReader(handle)) self.assertEqual([row['field'] for row in rows], ['performers']) candidate, = json.loads(rows[0]['candidates_json']) self.assertEqual((candidate['source'], candidate['provider'], candidate['source_kind'], candidate['official']), ('av_name', 'av_name', 'community', False)) self.assertEqual([item['name'] for item in candidate['value']], ['架空花子', '架空春子']) with (root/'metadata-source-errors-test.csv').open(encoding='utf-8-sig') as handle: errors = list(csv.DictReader(handle)) self.assertEqual([(row['source'], row['kind']) for row in errors], [('av_neme', 'incomplete_search')]) self.assertEqual(before, db.read_bytes()) if __name__ == '__main__': unittest.main()