ワンクリックで
scraper-expert
BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Planning principles, model selection, and scope rules for the Architect agent
Implementation rules for database migrations, Python scrapers, and Next.js web for the Engineer agent
BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent
Scraper test execution rules, output validation criteria, and report format for the Tester agent
Platform rules and field mappings for the 誠品生活日本橋 (eslite spectrum Nihonbashi) scraper
版元ドットコム Playwright scraper for Taiwan-related books (Publication Intel v3.1)
| name | scraper-expert |
| description | BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent |
| applyTo | .github/agents/scraper-expert.agent.md |
Read this at the start of every session before writing any scraper.
description and selection_reason.台湾出身, 台湾生まれ, Taiwan-born, born in Taiwan, or a Taiwan city + 出身), include history for the roots dimension. Do not add history for Taiwan university attendance, study-abroad history, or work activity in Taiwan alone.organizer when no 主催 label is present and the name appears in source text. Do not infer organizer from generic rental halls, universities, convention centers, aggregator platforms, or news/source names.go_taiwan filter in this order and do not reorder the stages. Stage 1 = title Taiwan-only rejection, Stage 2 = Taiwan-venue rejection with the Japanese-visitor exception, Stage 3 = Japan-keyword acceptance. If Stage 2 rejects, Stage 3 must not override it.taiwan_japan category in addition to art, history, etc.BaseScraper and implement scrape() → list[Event].source_id must be stable across runs — derive from URL slug or platform ID, never from title or list position.start_date explicitly. Never fall back silently to the page's publish/update date.raw_description only after the scraper has finished extracting the structured fields it needs from the body.
開催日時: YYYY年MM月DD日\n\n when a single event date is found in the page body.end_date is known and differs from start_date, prepend the full range 開催日時: YYYY年MM月DD日〜YYYY年MM月DD日.end_date must not prepend a fallback date prefix that would trigger the annotator SINGLE-DAY RULE..tw ドメインの SSL 証明書エラー (Missing Subject Key Identifier): 台湾政府サイト(startupterrace.tw 等)は TLS 証明書に Subject Key Identifier 拡張が欠落しており、requests のデフォルト verify=True が SSLCertVerificationError を raise する。該当サイトには verify=False + warnings.simplefilter("ignore") を使う helper _get() を定義すること。
def _get(url: str) -> requests.Response:
"""GET with SSL verification disabled (cert missing SKI extension)."""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return requests.get(url, headers=_HEADERS, timeout=15, verify=False)
(Incident: startup_terrace 99d9fde, 2026-05-17.)start_date/end_date must use tzinfo=timezone.utc for calendar dates: For all-day calendar dates, never pass JST-aware datetimes (tzinfo=_JST / timezone(timedelta(hours=9))) to Supabase. Use datetime(y, m, d, tzinfo=timezone.utc) so the date stays on the intended calendar day. For event times that are explicitly written in JST and need hour-level precision, convert the JST timestamp to UTC before storing it, then strip the tzinfo only if the caller expects a naive UTC datetime. Audit after every fix: grep -rn "tzinfo=_JST\|tzinfo=JST" scraper/sources/. (Incidents: Stranger b7dc34f, shin_bungeiza bcb6142.)Blog / RSS feed post fallback-date guard (2026-06-07): For long-text sources or blogs (e.g. note_creators, general news RSS), NEVER let the event start_date fall back blindly to the article's publishing date (pubDate). Doing so pollutes the active calendar with duplicate, non-event blog posts or misaligned dates. If an actual event date (e.g. M月D日) cannot be parsed from the title or description, set start_date = None and clear any fallback. This forces the annotator to either identify the real event date via GPT, or reject the post due to missing event evidence (M1 intake gate).Strict timezone adjustment before stripping tzinfo (2026-06-07): For RFC 2822 timestamps or other explicitly zoned-date values, perform timezone adjustments (e.g. converting UTC or foreign offsets to JST) before stripping the tzinfo. Dropping tzinfo on a raw UTC datetime (like converting 23:00 UTC directly to a naive datetime on the same day, which actually wraps to the next day's morning 08:00 JST in JST) causes a silent 1-day calendar drift.raw_description, name_ja, or speaker fields, call .replace("\x00", ""). Null bytes (\u0000) can appear in web-scraped text and cause Postgres error 22P05: unsupported Unicode escape sequence. (Incident: taiwan_prism.py c7e9b73 — ×\u0000栖来ひかり in speakers field broke all 13 events.)parent_event_id must be a real UUID, never a source_id string: parent_event_id is a uuid column. Passing a source_id string (e.g. f"scraper_{year}") causes Postgres 22P02. Use database.get_event_id_by_source(SOURCE_NAME, parent_source_id) to resolve the UUID. On the first run, when the parent UUID does not yet exist, emit the child with parent_event_id=None and log a WARNING. Do not skip the sub-event. The second run will upsert the correct UUID via the normal scrape cycle. (Incident: taiwan_prism c7e9b73)location_name ≠ actual venue (multi-page scrapers): When a platform shows events on a search/list page AND has a separate detail page, the list card may display the admin/managing branch (e.g. 新宿教室) rather than the actual venue (e.g. 立川サテライト教室). Always fetch the detail page to extract the real location_name. Pattern to look for on detail page: table row 備考 → 「会場名」 bracket pattern; fall back to card branch only if the detail page yields nothing. (Incident: asahiculture da3ac31, 2026-05-15.)"オンライン" in raw_title or "オンライン" in location_name, set location_name = "オンライン", location_address = None and skip CLASSROOM_ADDRESS_MAP lookup entirely. Failure to check causes the course's managing classroom address to be written to DB even though the event is online-only. (Incident: asahiculture d617e8c4, 2026-05-15.)
_is_online = "オンライン" in raw_title or "オンライン" in (detail["location_name"] or "")
if _is_online:
location_name, location_address = "オンライン", None
else:
location_name = detail["location_name"] or card_branch
location_address = detail["location_address"] or CLASSROOM_ADDRESS_MAP.get(location_name)
re.findall() for start/end date ranges, never re.search(): Strings like "2026/04/07火~2026/06/16火" contain two full dates. re.search() stops at the first match, silently dropping end_date. Use re.findall(pattern, text) then take matches[0] as start_date and matches[-1] as end_date. Single-day events have len(matches) == 1 → end_date = None. (Incident: asahiculture_8759178, 2026-05-15.)_fetch_detail* only collects paragraphs containing Taiwan keywords (台湾/Taiwan), any structured field in a non-keyword section — lecturer <h3>, schedule table, venue 備考 row — will never be captured. Always scan the full detail page with separate passes for each structured field type (table rows, headings) independent of keyword filtering. (Incident: asahiculture performer 村山 秀太郎 extracted as "記", 2026-05-15.)location_address ≠ location_name rule (ALL scrapers): location_address must NEVER equal location_name. When a scraper has a single combined "location" field, parse it: venue name → location_name, street address (using _ADDR_RE: 〒 or prefecture+city+street pattern) → location_address. If no real street address can be extracted, set location_address = None. This is enforced by auto_qa_address_is_venue_name detector. Also note: _ai_or_existing() in annotator preserves non-null DB values, so a scraper writing the wrong value cannot be corrected by the annotator.Japan/Taiwan address extraction rule (2026-06-07):
^). Many addresses start with 〒123-4567 which blocks static anchors. Use local searching for the 47 prefectures.臺 to 台 before extraction. Ensure the 22 cities (e.g., 台北市, 台東縣) are mapped to their standard names (stripping 市/縣 suffixes for chips).Hybrid Venue (Physical + Online) Rule (2026-06-07):
location_name to a joined string (e.g., “D-Bop”Jazz Club Sapporo / オンライン).location_address and its prefecture in location_prefectures so the event appears in regional filters and maps.field_corrections to these fields to prevent AI from reverting the event to "Online only".event_form == ["publication"] is pure. Source, category, title prefix, and placeholder text are drift evidence, not classification inputs.location_address, location_address_zh, location_address_en, business_hours, business_hours_zh, business_hours_en, and location_prefectures NULL with matching empty sentinels.is_paid, price_info, price_amount) and hide them only in pure-publication UI and JSON-LD. Price fields, location_name, and location_url are outside the seven-field NULL/clear policy.name_ja/name_zh keep [新刊出版], name_en uses [New Release], and periodical articles should use name_ja=[雑誌記事], name_zh=[期刊專文], name_en=[Periodical Article]. Do not reuse [期刊專文] in Japanese titles.performer = author, organizer_url = publisher homepage, official_url = book detail/official page, and Hanmoto date fallback order is 発売日 > 登録日.organizer= and organizer_type=["commercial_brand"]: location_name is stored in DB and shown in the venue column, but it does NOT appear in the admin event card. The 🏢 venue line in the event card is powered by the organizer field. A fixed-venue scraper that sets only location_name without organizer produces events that look venue-less in the admin list. Correct pattern (see kyoto_cinema.py, kino_shinsaibashi.py, sakurazaka.py):
location_name="シネマ・クレール 丸の内1・2",
location_address="岡山市北区丸の内1丁目5−1",
organizer="シネマ・クレール",
organizer_type=["commercial_brand"],
(Incident: cinemaclair.py and human_trust_cinema.py missing organizer=, 2026-05-15.)_TOKYO_KANTO_KEYWORDS) must never be added to any scraper._extract_dates() / _parse_*_date() helper must have an explicit return on every branch — never rely on Python's implicit None. A function that falls through returns None, and callers that do start, end = helper() will raise TypeError: cannot unpack non-iterable NoneType object — this is silently swallowed by outer try-except, causing the entire page's events to be dropped with no ERROR log. Add return None, None as the final fallback. (Incident: peatix _extract_peatix_dates, commit 2a9540c, 7 days of silent 0 events.)python main.py --source <name> (non-dry-run) after the fix is committed and peer-reviewed. This rule applies specifically to filter bugs that caused events to be silently dropped. A dry-run confirms the fix works but does NOT write to DB — the data gap remains until the next CI cycle.--source X is NOT cost-bounded — it always runs the full annotator/merger over the whole DB after scraping. scraper/main.py unconditionally calls annotate_pending_events() + enrich_movie_titles() + enrich_person_names() regardless of --source. These scan the entire events table for annotation_status='pending', so looping --source X over N sources locally triggers the annotator N times and burns ~N× the OpenAI quota of the daily cron. Use cases:
python main.py --dry-run --source X (no DB writes, no annotator/merger, no OpenAI calls).python main.py --source X (one call, accept the annotator pass as cost).python main.py (no --source) — scrapes all 114 sources then runs annotator/merger ONCE. Do not "optimize" this by sharding into per-source calls; it would increase cost, not reduce it.SCRAPERS in main.py in the same commit it is created. Audit command:
cd scraper && python3 -c "
import re, glob
registered = set(re.findall(r'(\w+Scraper)\(\)', open('main.py').read()))
for f in glob.glob('sources/*.py'):
c = open(f).read()
m = re.search(r'class (\w+Scraper)\b', c)
if m and m.group(1) not in registered and m.group(1) != 'BaseScraper':
print('UNREGISTERED:', m.group(1), f)
"
main.py change: Not only when adding new scrapers. Any refactor or chore commit touching main.py risks silently dropping registrations. Run the audit and confirm "ALL CLEAR" before git push.045d1fa, adding WasedaICL by rewriting main.py silently dropped 24 scrapers that ran without error for weeks (6a83c64 restored them). SCRAPERS count before any commit: python3 -c "import re; print(len(re.findall(r'\\w+Scraper\\(\\)', open('scraper/main.py').read())))" — if count drops vs prior commit, something was lost.import from main.pyScrapeClass() from SCRAPERS in main.pysb.table('events').delete().eq('source_name', '<source_name>').execute()
All 3 steps must happen in the same session. Missing step 3 leaves stale data visible in production.scraper/sources/<name>.py 作成・dry-run 確認済み。scraper/main.py — import + SCRAPERS 登録済み(下記 SCRAPERS audit で確認)。research_sources row — status = 'implemented'。research_sources.scraper_source_name = '<scraper key>' — 手動で必ず入力。auto_generate も Researcher も書かない。省略すると /admin/sources のイベント数が 0 になり、「今すぐ実行」ボタンも非表示になる(backend が scraper_runs を source_name で JOIN するため)。research_sources 未登録のまま scraper が CI に入る。update_source.py は researched/not-viable のみ対応。 implemented ステータスは update_source.py で設定できない — Supabase SDK で直接 upsert する必要がある:
sb.table("research_sources").upsert({
"url": "<listing_url>",
"name": "<display name>",
"status": "implemented",
"scraper_source_name": "<source_key>",
"scraping_feasibility": "medium", # or easy/hard
"agent_category": "event_listing",
}, on_conflict="url").execute()
venues,並設定 is_authoritative = true,scraper 只保留 location_name 原文。venue_registry.lookup_venue() 自動補齊 location_address、location_prefectures、venue_id 與 i18n 欄位。location_name,確認重複出現(至少 2 筆)後,將場館加入 scraper/_oneoff_seed_authoritative_venues.py 的 SEED_DATA,並附上 pre-flight diff 無衝突證據。is_multi_venue = true,讓 annotator 自動使用 prefectures 並將 location_address 保持為 None。_has_conflict() は NFKC 正規化 + 番地レベルのトランケートで住所を比較する(exact match 禁止)。\u3000・全形数字・大樓名有無・都道府縣前綴の有無はすべて compatible として扱われる。is_active=True)を対象にする。inactive gnews イベントの住所は不完全なことが多く、seed を誤ブロックする原因になる。時間空白回退 (Empty Business Hours Fallback):
當事件無 business_hours (場次/營業時間) 時,前端在 UI 上不應只顯示 "—"。
official_url 或 source_url 存在,前端會顯示 「請參照原始來源」 并加上指向該 URL 的超連結。這由 web/app/[locale]/events/[id]/page.tsx 中實作。電影院類的預設有料 (Cinema Default Pricing Fallback):
在 annotator.py AI enrichment 過程中,針對電影院類別(event_form 含有 screening 或 screening_with_talk,或 category 是 movie,或 source_name 符合電影院來源(如 cinema, cinemart, cineswitch, eurospace, human_trust, bungeiza, cinemarine, morc)):
is_paid 為空,且非台灣文化中心(source_name="taiwan_cultural_center" 或 organizer 含有台灣文化中心)主辦,預設為 is_paid = True(有料)。is_paid 為 True 且 price_info 為空白,預設為 price_info = "有料",避免前台因沒有票價顯示留空或破圖。Venue/address display must be normalized once:
When an address contains a postal prefix such as 〒123-4567, create one cleaned display string and reuse it across every user-facing surface that renders the same event. The narrative summary, FAQ answer, calendar export, and address card must all use the same cleaned value. Keep the raw address only for map/search queries when needed.
新しい scraper ファイルを作成・編集するたびに、task_complete 前に必ずこのコマンドを実行し、両方 ALL CLEAR を確認すること。
python3 -W ignore scraper/audit_post_build.py
出力例(正常):
✅ SCRAPERS: all registered
✅ research_sources: all implemented rows have scraper_source_name
🎉 ALL CLEAR — safe to commit
エラー例(未対応時):
❌ UNREGISTERED in main.py: TsutayaPortalScraper (sources/tsutaya_portal.py)
❌ scraper_source_name NULL: "蔦屋書店ポータル(台湾キーワード横断検索)" (status=implemented)
meta.json に source_name / class_name がない場合は generated.py 先頭を直接確認する: auto_scraper/runs/{id}/meta.json に source_name が含まれていない場合がある。その場合は auto_scraper/runs/{id}/generated.py の先頭数行を読んで class XxxScraper(BaseScraper): を探す。クラス名から source_name は snake_case 変換で導ける(BookAndBeerScraper → bookandbeer)。SCRAPERS リストは全員が同じ行/ブロックを編集するため、放置するほど conflict が深刻化。長期放置 → 複数の scraper 追加コミットが main に積まれ → マージ時に手動解決が必要になる(commit 7cedc68 の Artist Cafe 衝突事例)。sb.table('events').select('source_name,source_id,source_url').eq('id', '<uuid>').execute()
start_date / end_date must be datetime.datetime, NOT datetime.date: dedup_events in base.py calls .date() on start_date. Passing a bare date object raises AttributeError: 'datetime.date' object has no attribute 'date'. Always use datetime(y, m, d) when constructing dates in scrapers.category must be list[str], NOT a bare string: The DB column is text[]. Passing category="movie" raises malformed array literal at write time. Always use category=["movie"]. This fails silently at compile time and only surfaces on DB upsert.get_event_id_by_source(): When setting parent_event_id on a sub-event, call database.get_event_id_by_source(source_name, source_id) -> str | None to retrieve the parent's UUID. Never assume the UUID in the scraper or depend on insertion order. Example:
from scraper.database import get_event_id_by_source
parent_uuid = get_event_id_by_source("taiwanshi", parent_source_id)
Event(..., parent_event_id=parent_uuid)
requests.Session() must always mount HTTPAdapter with Retry: Any scraper that creates a requests.Session() must attach a retry adapter in __init__. Without it, a single transient network blip from GitHub Actions runners raises Max retries exceeded and triggers Sentry — even when the target site is healthy. Required pattern:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
_retry = Retry(
total=3,
backoff_factor=2,
status_forcelist=[429, 500, 502, 503, 504],
raise_on_status=False,
)
self._session.mount("https://", HTTPAdapter(max_retries=_retry))
self._session.mount("http://", HTTPAdapter(max_retries=_retry))
Backoff: 2s → 4s → 8s. Mount both https:// and http://.講演者 → 講師 → 登壇者 的 fallback,並同時抽取 主催(必要時 共催)。此外,需將 主催: 與 講師: 顯式寫入 raw_description,確保 annotator 可在結構化文本中穩定回填 organizer / performer,避免觸發 auto_qa_missing_organizer / auto_qa_missing_performers。<strong> Strip 後空格問題Rule: WordPress RSS の <description> を BeautifulSoup で parse すると、<strong> 等の inline タグ除去後に数字の間に空白が挿入される(例:"2026 年 1 月 31 日")。日付 regex では \d と漢字の間を \s* で繋ぐこと。
# ✅ 正確:\s* で tag-strip artifact を吸収
_DATE_RE = re.compile(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日")
# ❌ 誤り:スペース固定 or スペースなし — WordPress 環境では失敗する
_DATE_RE = re.compile(r"(\d{4})年(\d{1,2})月(\d{1,2})日")
<a href> URL は .get_text() で消えるRule: WordPress RSS の <content:encoded> CDATA には Peatix・チケット販売サイト・公式サイトへのリンクが <a href="..."> アンカーとして埋め込まれる。BeautifulSoup(content_html, "html.parser").get_text() でテキスト変換すると href 属性が消えるため、正規表現によるプレインテキスト検索では URL を検出できない。
必ず生 HTML 文字列を別途 BS4 でパースし find_all("a", href=True) を走査する関数を用意すること:
def _extract_peatix_url_from_html(html_text: str) -> Optional[str]:
"""Extract Peatix event URL from HTML anchor href attributes.
Priority: peatix.com/event/NNN (specific event page) over
*.peatix.com subdomain (organizer channel page).
Channel links often appear as banners BEFORE the event link.
"""
soup = BeautifulSoup(html_text or "", "html.parser")
channel_url: Optional[str] = None
for a in soup.find_all("a", href=True):
href: str = a["href"]
if "peatix.com" not in href:
continue
href = href.rstrip("./,、))")
# Prefer /event/NNN path — return immediately
if re.search(r"peatix\.com/event/\d+", href):
return href
if channel_url is None:
channel_url = href
return channel_url
呼び出し側でテキスト検索と HTML 検索を OR で組み合わせる:
_peatix = _extract_peatix_url(content_text) or _extract_peatix_url_from_html(content_html)
⚠️ URL 種別の優先度: Peatix には 2 種類の URL が存在する:
peatix.com/event/NNN/view — 個別イベントページ(チケット購入・詳細情報あり)← 優先org.peatix.com — 主催者チャンネルページ(イベント一覧のみ)← fallbackHTML 内に両方が現れる場合、チャンネルページが先に出現することが多い。常に全アンカーを走査して /event/NNN を優先すること。
適用範囲: WordPress RSS を使う全 scraper(ftip、その他 WordPress ベースのソース)。
(Incidents: ftip ee870f7 CDATA extraction, 34368e3 channel-vs-event priority, 2026-05-17.)
Rule: Peatix は headless ブラウザのロケール設定に応じて https://peatix.com/us/event/{id} や /ja/event/{id} 形式の URL を返すことがある。これらは実際のイベントページ(https://peatix.com/event/{id})へのリダイレクトが失われると 302 → トップ になり broken link となる。URL は 収集段階(_search_events・_scrape_group_events)で正規化すること。
import re
_PEATIX_LOCALE_RE = re.compile(
r"(https://peatix\.com)/[a-z]{2,5}(?:-[A-Z]{2})?(/event/)"
)
def _normalize_peatix_url(url: str) -> str:
"""Strip locale prefix from Peatix URLs.
https://peatix.com/us/event/123 → https://peatix.com/event/123
https://peatix.com/ja/event/123 → https://peatix.com/event/123
https://peatix.com/event/123 → unchanged
"""
return _PEATIX_LOCALE_RE.sub(r"\1\2", url)
収集ループでの適用:
# _scrape_group_events / _search_events 両方に適用
full = _normalize_peatix_url(full.split("?")[0])
既存 DB に locale 付き URL が混入している場合の修正手順:
sb.table('events').select(...).like('source_url','%/us/event/%') で件数確認source_id = hashlib.md5(new_url).hexdigest()[:16] を計算source_id で既存レコードが存在するか確認
/us/ 版を merged_into_event_id 設定して soft-deletesource_url・source_id を直接 update(Incidents: peatix e9c6f80b 2026-05-17, 55d766ae 2026-05-19, commit 8b901ec.)
location_name フォールバックに組織名を使わないRule: 会場抽出に失敗した場合、組織名定数(例: LOCATION_NAME = "台湾原住民族との交流会")を location_name のフォールバックに使わないこと。組織名が会場として DB に入り、UI で「会場:台湾原住民族との交流会」と誤表示される。
# ✅ 正確:会場不明なら None
location_name = venue_name if venue_name else None
# ❌ 誤り:組織名定数をフォールバックに使う
location_name = venue_name if venue_name else LOCATION_NAME # "〇〇協会" が venue になる
会場が不明な場合は location_name = None とし、annotator または手動修正に委ねること。
(Incident: ftip 278e6d8, 2026-05-17.)
適用範囲:WordPress 6.x 以降の全 RSS ベース scraper。get_text() / get_text(strip=True) を問わず発生する。
Reference incident: 2026-05-12 — nittai_toumonkai.py(WordPress 6.9.4)の <description> で 2026 年 1 月 31 日 形式を確認。
(?!<後綴詞>) 誤匹配防止Rule: 会場、場所、開催場所 等の venue キーワード regex に負向前瞻を追加して複合語の誤マッチを防ぐ。
# ✅ 正確:会場受付 を venue として拾わない
_VENUE_RE = re.compile(r"会場(?!受付)[::]\s*(.+)")
# ❌ 誤り:会場受付 も venue として抽出してしまう
_VENUE_RE = re.compile(r"会場[::]\s*(.+)")
要注意の複合語一覧:会場受付、会場費、会場変更、会場案内
各 regex 追加時に上記複合語を検討し、必要に応じて (?!受付|費|変更|案内) を付与すること。
Reference incident: 2026-05-12 — nittai_toumonkai.py で 会場受付 が venue name として誤抽出。
get_text 使い分け+(1 回以上)にするvenue・日時ラベル(会場、場所、開催場所、日時 等)の後ろに続くセパレーター文字クラスは必ず + にする。* を使うと本文中の同名の一般名詞(例:称揚する場所」...)にマッチしてゴミテキストを venue として取得してしまう。
# ✅ 正確:セパレーターが最低 1 文字必要
_VENUE_RE = re.compile(r"(?:会場|場所|開催場所)[ \s::]+([^\n]{3,60})")
# ❌ 誤り:セパレーターなし(0 回)でもマッチ → 本文中の一般名詞に誤マッチ
_VENUE_RE = re.compile(r"(?:会場|場所|開催場所)[ \s::]*([^\n]{3,60})")
get_text("\n") と get_text(" ") の使い分け| 用途 | 推奨 | 理由 |
|---|---|---|
venue・日時(構造依存、[^\n] を使う regex) | get_text("\n", strip=True) | HTML ブロック境界が \n に変換され [^\n] がブロック内で停止する |
| 日付・概要・キーワード判定(改行不要) | get_text(" ", strip=True) | 1 行テキストで regex が扱いやすい |
両バリアントを変数として保持するのが安全:
full_text = soup.get_text(" ", strip=True).replace("\x00", "") # 日付・概要用
full_text_nl = soup.get_text("\n", strip=True).replace("\x00", "") # venue・日時用
mv = _VENUE_RE.search(full_text_nl) # ✅ [^\n] がブロック境界で停止
md = _DATE_RE.search(full_text) # ✅ 改行なし 1 行テキストで日付 regex
get_text(" ") のみで venue regex を走らせると、会場行と次のセクション(プログラム・講師情報 etc.)が 1 行に結合されるため、[^\n]{3,60} が 60 文字まで次のセクションを取り込む。
Reference incident: 2026-05-15 — snet_taiwan.py で 場所 が本文に誤マッチ(量詞 * 修正)後も get_text(" ") によりプログラム情報が混入(get_text("\n") 導入で解決)。
unicodedata.normalize("NFKC") 事前変換Rule: 日本語ウェブページの数字は全角(2026年)で記述されている場合がある。parse 前に unicodedata.normalize("NFKC", text) で半角に統一すること。
import unicodedata
def _fw_to_ascii(self, text: str) -> str:
"""全形数字・記号を半形に変換する。"""
return unicodedata.normalize("NFKC", text)
# parse pipeline
text = self._fw_to_ascii(raw_text)
m = _DATE_RE.search(text) # \d が全角を拾い損ねない
適用範囲:全角数字が出現しうる全 scraper(特に団体サイト・勉強会系)。BeautifulSoup.get_text() は全角のまま返すため変換は必須。
Reference incident: 2026-05-12 — nittai_toumonkai.py 本文に 2026年 が出現。
Rule: 日本語テキストの時間レンジ(開始〜終了)を正規表現でパースする際は、以下 5 種類の区切り文字すべてを character class に含めること。
| 文字 | Unicode | 典型ソース |
|---|---|---|
〜 | U+301C WAVE DASH | 一般的 |
~ | U+007E ASCII TILDE | 一般的 |
~ | U+FF5E FULLWIDTH TILDE | 一般的 |
- | U+002D HYPHEN-MINUS | ASCII |
- | U+FF0D FULLWIDTH HYPHEN-MINUS | 学術・waseda 系 |
_TIME = r'\d{1,2}:\d{2}'
# ✅ 全 5 種の区切り文字を網羅
m = re.search(rf'({_TIME})\s*[〜~~\--]\s*({_TIME})', text)
annotator.py の _extract_hours_from_raw() はこのパターンを実装済み(commit b3b32b3)。新たに時間パーサを書く際は同じ character class を踏襲すること。
修正時のテスト手順:
cases = [
'15:05-17:00', # U+FF0D
'15:05~17:00', # U+FF5E
'15:05〜17:00', # U+301C
'15:05-17:00', # ASCII
'15:05~17:00', # U+007E
]
for t in cases:
assert re.search(rf'({_TIME})\s*[〜~~\--]\s*({_TIME})', t), f'MISS: {t!r}'
Reference incident: 2026-05-30 — waseda_taiwan event 75a46729 で business_hours が 15:05 のみ(17:00 欠落)。commit b3b32b3。
unquote(href)Rule: Jimdo, 一部の WordPress, FC2 等のCMSは、<a href> の日本語パスをあるページでは URL-encode し、別のページでは生の日本語文字で出力する。比較・重複排除を行う前に unquote() で正規化すること。
from urllib.parse import unquote, urljoin
href = a.get("href", "")
normalized = unquote(href) # %E3%83%96%E3%83%AD%E3%82%B0 → ブログ
full_url = urljoin(base_url, normalized)
注意:urljoin は encode 済み URL も未 encode URL も正しく処理するが、比較は必ず decode 後に行うこと(/ブログ/ と /%E3%83%96%E3%83%AD%E3%82%B0/ は同一 URL だが文字列比較では不一致)。
Reference incident: 2026-05-12 — tsudoi_osaka.py(Jimdo CMS)の href に encoding 不統一が確認された。
requests.post + 302 追跡大学・機関サイトの検索機能は GET パラメータでなく POST body を使うことが多い。
必須確認:ブラウザ devtools の Network タブまたは curl -v でフォームの method 属性を確認する。
# ✅ POST 検索 — Cookie なし、302 自動追跡
resp = requests.post(
"https://www.wuext.waseda.jp/course/search-list/",
data={"keyword": "台湾", "page": 1},
allow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
timeout=30,
)
# ❌ GET パラメータは無視される(POST フォームサイト)
resp = requests.get("https://www.wuext.waseda.jp/course/search-list/?keyword=台湾")
規則:
requests.Session() を使う。allow_redirects=True で 302 を自動追跡する。form[method="post"] サイトへの GET リクエストは検索結果でなくデフォルトページを返す。結果が 0 件の場合は必ず POST を試すこと。Reference incident: 2026-05-13 — wuext_waseda.py で ?keyword=台湾 GET が無視され 0 件。POST に変更で正常動作。
大学・機関サイトの detail ページは固有の id または class を持つコンテンツコンテナを使う。
# ✅ 早稲田エクステンション: id="course"
content = soup.find(id="course") or soup.find("main") or soup
# ❌ find('body') はナビゲーション・サイドバー・フッターを含む — キーワード判定が不正確
content = soup.find("body")
手順:
curl -s <url> | grep -E 'id=|class=' | head -30 でコンテナ候補を列挙する。id を優先し、なければ class セレクターを使う。find('main') は最終手段。要注意のサイト別コンテナ例:
id="course"div.course-detail / 備考 th/td row大学講座・機関イベントはタイトルに「台湾」を含まず、本文のみで台湾を言及する場合が多い(例: 「緊迫する世界状勢と現代地政学」「台湾有事」を内容で扱う)。
_TAIWAN_KEYWORDS = ("台湾", "台北", "台中", "高雄", "台南", "日台", "台日", "中華民国")
def _is_taiwan_content(self, title: str, detail_soup) -> bool:
"""タイトルまたは詳細ページ本文に台湾キーワードが含まれるか判定する。"""
if any(kw in title for kw in _TAIWAN_KEYWORDS):
return True
content = detail_soup.find(id="course") or detail_soup
body_text = content.get_text(" ", strip=True)
return any(kw in body_text for kw in _TAIWAN_KEYWORDS)
規則:
_TAIWAN_KEYWORDS は最低限 台湾 日台 台日 を含めること。Reference incident: 2026-05-13 — wuext_waseda.py でタイトルのみフィルタにより「緊迫する世界状勢と現代地政学」等が脱落。
Rules that apply to ALL scrapers when constructing raw_description and start_date/end_date.
開催日時: prefix — complete format when end_date is knownWhen a scraper already knows end_date AND end_date ≠ start_date, the 開催日時: prefix in raw_description must include the full date range:
開催日時: YYYY年MM月DD日〜YYYY年MM月DD日
Writing only the start date (開催日時: YYYY年MM月DD日) tells GPT there is a single day → SINGLE-DAY RULE fires → end_date is set to start_date, discarding the correct value already held by the scraper.
Note: annotator.py's annotation.get("end_date") or event.get("end_date") fallback is blind to non-null wrong values — it only activates when GPT returns null. If GPT returns a non-null but incorrect end_date (e.g. same as start_date via SINGLE-DAY RULE), the scraper's correct value is silently discarded.
When a news-type scraper (google_news_rss, prtimes, nhk_rss, etc.) cannot extract a complete date from the article (i.e. the article only mentions a month/day or no date at all), embed the RSS pub_date as a year anchor in raw_description:
(記事配信日: YYYY年MM月DD日)
Insert this before the article body text. Without a year anchor, GPT may infer the wrong year (e.g. guessing 2024 when the correct year is 2026).
annotator.py SYSTEM_PROMPT Rule 10 covers N日間 → end_date = start_date + (N-1) days, but scrapers should also attempt self-resolution rather than defaulting to end_date = start_date:
import re
from datetime import timedelta
m = re.search(r'(\d+)日間', raw_description)
if m:
n = int(m.group(1))
end_date = start_date + timedelta(days=n - 1)
Similarly, N週間 → N × 7 days. Apply BEFORE falling back to end_date = start_date.
When a listing table cell has only a term name (e.g. 2025年度 冬期 全4回) with no explicit date range,
the actual start/end dates are often embedded in the detail page body as (YYYY/MM/DD) or YYYY年MM月DD日.
Priority order for on-demand / date-less courses:
07月04日~07月25日)(YYYY/MM/DD) or YYYY年MM月DD日 from detail page body — take earliest as start_date, latest as end_date春期→4/1, 夏期→7/1, 秋期→10/1, 冬期→1/1 of next calendar year)_DETAIL_DATE_PARENS_RE = re.compile(r"\((\d{4})/(\d{1,2})/(\d{1,2})\)")
def _extract_detail_dates(detail_soup):
text = (detail_soup.find(id="course") or detail_soup).get_text(" ", strip=True)
found = []
for m in _DETAIL_DATE_PARENS_RE.finditer(text):
found.append(datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), tzinfo=timezone.utc))
found.sort()
return (found[0], found[-1] if len(found) > 1 else None) if found else (None, None)
Always try detail page dates before term fallback — term fallback yields YYYY-01-01 / YYYY-04-01
which shows as "日期未定" on the web UI and misleads users.
Reference incident: 2026-05-14 — wuext_waseda event 30bdfc30「台湾と日本―興味の台湾案内」
had start_date=2026-01-01 (term fallback). Actual viewing period (2025/11/26)~(2026/04/30)
was in the detail body. Fixed in commit bacd4cd.
or event.get("end_date") fallback — blind spotThe pattern annotation.get("end_date") or event.get("end_date") only rescues the scraper's value when GPT returns null. When GPT returns a non-null wrong value (most commonly SINGLE-DAY RULE: end_date = start_date), the or branch is never reached — the wrong value is written to DB. Fix: always embed the correct date range in raw_description (see § 開催日時: prefix above) so GPT never needs to fall back to SINGLE-DAY RULE in the first place.
問題:Contentful 年度系列展使用 scheduleStartsOn=YYYY-01-01 作為財年佔位符。
偵測:start_date.month == 1 and start_date.day == 1
修復:從 URL slug 末尾提取真實日期:
slug_m = re.search(r"/(\d{4}-\d{2}-\d{2})$", slug)
if slug_m and start_date and start_date.month == 1 and start_date.day == 1:
start_date = self._parse_date(slug_m.group(1))
驗證:乾跑後確認無事件 start_date 為 Jan 1。
Reference incident: 2026-05-05 — event 6a91a4ce (アジア美術の歩き方 東アジア編) scheduleStartsOn=2026-01-01 為佔位符,真實日期 2026-04-18 在 slug 末尾 (commit a1e58a9)。
Rule: Every a["href"] value that may be a relative path must be converted via urljoin before storing in source_url or detail_url.
from urllib.parse import urljoin
# ✅ 正確:無論 href 是相對或絕對路徑,都透過 urljoin 轉換
source_url = urljoin(page.url, a["href"])
# ❌ 錯誤:直接存相對路徑,會產生 ../news/n*.html 等無效 URL
source_url = a["href"]
Incident: hakusuisha.py 的 ../news/n*.html 直接存入 DB(commit 1b344f7),導致 10 筆事件 source_url 404。
Auto-generated scraper 的 detail_url 補全規則(spec_to_code template 生成的 _extract_cards 模式):
# ✅ 最正確:urljoin(page.url, href) — 處理 /path、../path、relative/path 等所有形式
from urllib.parse import urljoin
if detail_url and not detail_url.startswith(("http://", "https://")):
detail_url = urljoin(page.url, detail_url)
# ❌ 不完全:BASE_URL + path は ../news/n*.html 形式を解決できない
# if detail_url and not detail_url.startswith("http"):
# detail_url = f"{BASE_URL}/{detail_url}"
BASE_URL 文字列結合は / 前導パスには機能するが ../news/n*.html 形式では {BASE_URL}/../news/... という無効 URL になる。urljoin(page.url, href) が唯一の正解。Incidents: cine_gallery.py 事件 cdf5e555(2026-05-14); hakusuisha.py ../news/n*.html → 2026-05-30 c099bcb で修正。
separator="\n"Rule: 任何需要保留行結構的文字提取(排程、場次、地址、時間表等),必須使用 separator="\n"。
# ✅ 正確:保留行結構
text = element.get_text(separator="\n", strip=True)
# ❌ 錯誤:連續 inline 元素的文字直接拼接,時間/場次資訊擠在一起
text = element.get_text(strip=True)
Incident: gguide_tv.py 排程文字缺 separator="\n",時間資訊擠在一起(commit a895e07)。
end_date と business_hours 完全規則適用対象: 全ての cinema scraper(
category=["movie"]を持つ全 scraper)
日本の電影院 scraper は3タイプに分類される。タイプごとに end_date と business_hours の取得戦略が異なる。
特徴: 主サイト(eiga.starcat.co.jp)は映画情報・あらすじのみ。場次時間と排片日程は別の票務平台(starcat-ticket.com など)に存在する。
end_date 規則: 日本の映画館は毎週木曜日に翌週(金曜〜木曜)の上映スケジュールを発表する。したがって:
end_date = 票務スケジュール内のその映画の最後の日(当週木曜)_build_ticket_schedule() は dict[str, tuple[str, Optional[datetime]]] を返す: (business_hours_str, last_date_utc)end_date = Noneend_date は自動延伸(滚动视窗)business_hours 規則: _build_ticket_schedule(ticket_url) から取得。形式: M/DD(曜): HH:MM〜HH:MM(複数日は \n 区切り)
raw_description 前綴必須規則(SINGLE-DAY RULE 防止):
# end_date が start_date と異なる場合、必ず完整な期間範囲を前綴に入れる
if start_date and schedule_end and schedule_end != start_date:
date_prefix = f"上映期間: {start_date.year}年{start_date.month}月{start_date.day}日〜{schedule_end.year}年{schedule_end.month}月{schedule_end.day}日"
# ❌ NG: "2026年5月15日(金)より公開" のみ → annotator SINGLE-DAY RULE → end_date = start_date
参考実装: starcat_cinema.py — TICKET_SCHEDULE_URLS + _build_ticket_schedule() + _lookup_schedule_entry() + _lookup_end_date() + _lookup_business_hours()
特徴: 映画詳細ページ内に上映スケジュール(日付 + 時刻)が直接含まれる。
end_date 規則: max(dates in schedule) — スケジュール内の最終日
end_date = max(parsed_dates, default=None)
business_hours 規則: HTML スケジュール要素から直接抽出。常見容器:
div.schedule-program (shin_bungeiza)div.schedule-table / table.schedule (各映画館)dl.showtime / ul.times (単館映画館)p.nihon-date + div.program 組合せ (shin_bungeiza パターン)日付ヘッダー(<h2> / <p class="nihon-date">)と上映時刻(<div class="schedule-program">)が別要素の場合は両者を組み合わせる:
business_hours = "\n".join(
f"{date_label}: {' '.join(times)}"
for date_label, times in schedule_map.items()
)
特徴: 現在上映中 / 近日公開映画のカード一覧ページ。詳細な場次時間は別ページまたは別プラットフォームに存在する。
end_date 規則: カードに "M/D まで" / "〜M/D" / "終映日:M/D" / "※M/D で上映終了" ラベルがある場合はそこから解析。ない場合は None(推測禁止)。
business_hours 規則: リストページに場次時間がない場合は None。ただし詳細ページに時刻がある場合は詳細ページを取得して抽出する(コスト対効果を考慮すること)。
end_date = start_date の扱い: cinema scrapers では原則として end_date = start_date を新たに作らない。end_date を持つ場合は少なくとも start_date + 1日 にする。例外は movie-extend 用の明示的 fallback だけで、その場合に限り end_date = start_date を許容する。(Incident: kyoto_cinema, 2026-05-20)business_hours = "" 禁止: 場次が取得できない場合は None を設定。空文字列は DB に不要なデータを残す。end_date 禁止: 「通常2〜3週間上映」などの仮定で end_date を算出してはいけない。ソースから取得できない場合は None。business_hours = None は scraper bug: サイトを目視確認して時刻要素のセレクタを追加すること。event_form=["screening"] 必須: 全 cinema scraper は event_form=["screening"] を設定すること。DB check constraint(migration 047)の valid 値: 'exhibition','screening','lecture','performance','market','workshop','conference','networking','screening_with_talk','tour','competition','tasting','broadcast','study_abroad','other'。"film_screening" は DB に存在しない(constraint エラー)。(Incident: kyoto_cinema・sakurazaka・kino_shinsaibashi・human_trust_cinema で "film_screening" を誤設定 → constraint 違反、commit で revert)一部 CMS(TTCG など)は data-date="2026-05-15T00:00:00+09:00" のような JST-aware ISO 文字列を出力する。.replace("+09:00", "") パターンは禁止 — JST offset を除去しても naive datetime が生成されるだけで UTC 変換にならない。
# ✅ 正確:JST-aware parse → UTC midnight
dt_jst = datetime.fromisoformat(data_date) # 2026-05-15 00:00:00+09:00
start_date = datetime(dt_jst.year, dt_jst.month, dt_jst.day, tzinfo=timezone.utc)
# ❌ 誤り:offset を strip した naive datetime になる
start_date = datetime.fromisoformat(data_date.replace("+09:00", ""))
Incident: human_trust_cinema commit 7849021。
raw_description に単一の日付しか含まれない場合、annotator は end_date = start_date と設定する(SINGLE-DAY RULE)。Cinema scraper では以下を守ること:
raw_description の前綴に必ず上映期間全体を記載する: 上映期間: YYYY年M月D日〜YYYY年M月D日end_date が取得できた場合は前綴を期間表示に置き換える(単日 より公開 記述のまま放置しない)end_date が取得不可の場合は date prefix を入れない: サイトに end_date 情報がない場合は、raw_description に日付前綴を追加しない。start_date はフィールドに正しく格納済みなので raw_description に繰り返す必要はない。(Incident: human_trust_cinema commit 7849021)稽核表に新行を追加する前に、ファイルの実在を確認すること:
ls scraper/sources/<name>.py
不存在のファイルを稽核表に記載すると後続の修復作業で混乱を招く。(Incident: ciemarine 行 — ファイル不存在のまま記載されていたため削除)
| Scraper | Type | end_date | business_hours | 状態 |
|---|---|---|---|---|
| cinemart_shinjuku | 2 | ✅ max(dates) | ✅ cineticket.jp | 完全準拠 |
| shin_bungeiza | 2 | ✅ h2 dates max | ✅ schedule-program | 完全準拠 |
| starcat_cinema | 1 | ✅ 木曜末日 | ✅ starcat-ticket.com | 完全準拠 |
| rightscube | 2 | ✅ THEATER区段 | ✅ business_hours_text | 完全準拠 |
| ks_cinema | 2 | ✅ 表格期間 | ✅ schedule_text (commit 23e417f) | 完全準拠 |
| kino_shinsaibashi | 3 | ✅ 終映日 | ❌ None(JS 驅動,Type 3 可) | 完全準拠(screening + prefix, commit 544bbc4) |
| kyoto_cinema | 3 | ✅ 終映日M/D; fallback=start_date(movie-extend 用) | ✅ homepage time slots | 完全準拠(screening + prefix, commit e91f5cd; movie-extend fallback, 2026-05-20) |
| cineswitch_ginza | 3 | ✅ M/D まで | ❌ None(Type 3 可) | 完全準拠(UTC fix, commit e91f5cd) |
| theater_enya | 3 | ✅ 期間文字 | ❌ None(Type 3 可) | 完全準拠(UTC fix, commit e91f5cd) |
| cinewind | 2 | ✅ YYYY/M/D | ❌ None(Type 2, 追加調査要) | UTC 修正済み(commit e91f5cd) |
| ciema | 2/3 | ✅ 週表頭 | ❌ None(Type 3 可) | 完全準拠(UTC fix, commit e91f5cd) |
| cinemadict | 2 | ✅ 完整期間(UTC fix) | ✅ date_text HH:MM(commit 544bbc4) | 完全準拠 |
| ycam_cinema | 2 | ✅ 節目期間 | ❌ None(Type 2, 追加調査要) | UTC 修正済み(commit e91f5cd) |
| sakurazaka | 3 | ✅ 上映中/予定 | ❌ None(Type 3 可) | 完全準拠(screening, commit e91f5cd) |
| uedaeigeki | 2 | ✅ 上映日程 | ❌ None(Type 2, 追加調査要) | UTC + prefix 修正済み(commit e91f5cd) |
| human_trust_cinema | 3 | ❌ None(サイト非公開, JS-driven) | ❌ None(同上) | UTC+event_form 完了(commit 7849021→revert), end_date はサイト制限 |
| theater_kino | 2 | ✅ 静的HTML | ❌ None(Type 2, 追加調査要) | UTC + prefix 修正済み(commit e91f5cd) |
新規 cinema scraper 作成時は、上記稽核表に行を追加すること。
Incidents:
1ffb98e)— _parse_nihon_date_only() が <h2> 日付のみ取得し <div class="schedule-program"> の場次時間を無視。starcat-ticket.com から別途取得。end_date=None が annotator SINGLE-DAY RULE で start_date に上書きされた。TAIWAN_VENUE_KEYWORDS 必須使用完整行政單位名(台北市、新北市),禁用裸縮寫(台北、新北)以避免日本地名假陽性grep -rn "<keyword>" scraper/sources/ 確認不在任何已知日本地名中updated_at > confirmed_at 時重新觸發;根治需修正關鍵字Reference incident: 2026-05-05 — '新北' 匹配大阪市 新北島,event 371cf624 (GRAFFYHALL) 連續三次 auto_qa_taiwan_venue (commit 6b7174a)。
_extract_after_label() を使う構造化テキスト scraper の _STOP_LABELS 管理_extract_after_label(text, label_re) は _STOP_LABELS に登録された語で抽出を打ち切るが、同一行に出現しうる全ラベルが未登録だと venue_raw に発表者・対象者情報が混入する。
Rule: _STOP_LABELS は「同一行に現れうる全ラベル」を網羅すること
# 典型的な早稲田台湾研究所ソース行:
場所:〇〇教室 講演者:郭智輝氏(...) モデレーター:久保克行 対象:学生・一般
講演者/モデレーター/対象 が _STOP_LABELS に未登録だと、venue_raw が 〇〇教室 講演者:郭智輝氏... 対象:... になり、会場: {venue_raw} として raw_description に混入 → annotator が全テキストを location_name に格納する。
学術イベント系 raw_description 構成ルール
講演者/モデレーター が存在する場合、raw_desc_parts に独立したエントリとして追加する:
speaker_raw = _extract_after_label(content, r"講演者") # stops at モデレーター:
moderator_raw = _extract_after_label(content, r"モデレーター") # stops at 対象:
raw_desc_parts = []
if date_raw: raw_desc_parts.append(f"開催日時: {date_raw}")
if venue: raw_desc_parts.append(f"会場: {venue_raw[:200] or venue}")
if speaker_raw: raw_desc_parts.append(f"講演者: {speaker_raw}")
if moderator_raw: raw_desc_parts.append(f"モデレーター: {moderator_raw}")
会場: 行に発表者情報を混ぜると annotator は performers を正しく抽出できない。
Reference incident: 2026-05-30 — waseda_taiwan event 75a46729、_STOP_LABELS 未登録で venue に発表者混入 (commits 0604a6f, b3be645)。
Rule: performer 欄位只能填人名,不可填職稱/職業描述。
下列詞彙單獨出現時必須視為職稱並設 performer = null:
シェフ、料理人、講師、先生、司会、ゲスト、ホスト、演者、指揮者、伴奏者主廚、老師、講師、主持人判斷規則:
performer 字串若只含職稱詞且無可辨識人名(CJK 人名 ≥ 2 字、或拉丁姓名)→ 設 null_extract_performer_from_raw() 的 role list regex 命中後,必須確認後方緊跟的是人名而非獨立職稱Regex guard 範例(加在 _extract_performer_from_raw 最後):
_JOB_TITLE_ONLY_RE = re.compile(
r"^(シェフ|料理人|講師|先生|司会|ゲスト|ホスト|演者|指揮者|伴奏者|主廚|老師|主持人)$"
)
if _JOB_TITLE_ONLY_RE.fullmatch(candidate.strip()):
return None
Reference incident: 2026-05-08 — 湾.味(ワンウェイ) 台湾料理体験会 performer='シェフ' 應為 null。
Rule: 合法情況下絕大多數時間返回 0 events 的 scraper,必須加入 health_check.py 的 ZERO_EVENT_OK_SOURCES 集合,避免 CI 每日觸發假「missing」警告。
加入標準(三項全符合):
反模式:不應因「通常有 0 events」就忽略加入此集合——遺漏會導致每次 CI 都需人工確認雜訊。
實作位置:scraper/health_check.py → ZERO_EVENT_OK_SOURCES: set[str]
Reference incident: 2026-05-08 — whitestone_gallery 加入 ZERO_EVENT_OK_SOURCES(台灣藝術家展覽為偶發性)。
For requests + BeautifulSoup static HTML scrapers, if the HTTP header omits charset but the page declares <meta charset="UTF-8">, do not parse resp.text. Pass resp.content to BeautifulSoup, or set resp.encoding explicitly before reading text.
Validate Japanese markers such as 月, 日, and 開催日時, then confirm the scraped text has no mojibake or U+FFFD before deciding that 0 events are expected.
ZERO_EVENT_OK is not limited to cinemas and galleries. Keyword-filtered feeds, listings, and aggregators can legitimately return 0 events after the scraper logic is verified.
Current examples: nhk_rss, walkerplus, bookandbeer, internet_museum.
If a source just had an active selector or encoding fix, keep it out of ZERO_EVENT_OK until dry-run proves the parsing path works and quiet periods are expected. johakyu stayed monitored after switching the schedule parser to resp.content.
Reference incident: 2026-07-10 — johakyu used requests resp.text on a UTF-8 page without charset header, mojibaked schedule text and silently returned 0 events; fix used resp.content. Same update added nhk_rss, walkerplus, bookandbeer, internet_museum to ZERO_EVENT_OK_SOURCES after classifying them as verified keyword-filtered long-tail sources.
Rule: organizer_zh/organizer_en 若含有在 raw_title + raw_description 中完全找不到的內容,即為 FC 跨事件污染(cross-event field_corrections pollution)。
污染機制:annotator 的 few-shot context 中,若前一事件的 FC 資料帶入了 organizer_zh/en,GPT 可能將該值套用到當前事件——即使兩者完全無關。annotation_status = annotated 不會重新觸發驗證,因此污染可長期潛伏。
偵測查詢(定期執行):
SELECT id, organizer_zh, organizer_en, source_name
FROM events
WHERE organizer_zh IS NOT NULL
AND raw_description NOT ILIKE '%' || split_part(organizer_zh, ' ', 1) || '%'
AND raw_title NOT ILIKE '%' || split_part(organizer_zh, ' ', 1) || '%'
LIMIT 50;
修正流程:確認污染後,field_corrections で organizer_zh・organizer_en を FC lock(annotator の再上書き防止)。
null に設定して FC lock(幻覚値より null が安全。UI は organizer(ja)へ fallback する)。追加検出サイン:organizer_zh 値に (AI翻訳) / (AI翻譯) サフィックスが含まれる場合は即 null クリア + FC lock。⚠️ auto_qa.py _detect_performer_ai_marker は performer_zh/en + category=movie 限定であり organizer_zh は自動検出されない。定期的に以下 SQL で補完すること:
-- organizer_zh の AI マーカー残留スキャン
SELECT id, organizer_zh, source_name
FROM events
WHERE is_active = true
AND (organizer_zh LIKE '%AI翻譯%' OR organizer_zh LIKE '%AI翻訳%')
LIMIT 50;
Reference incidents:
fe03288b/b8621ee9(湾.味 台湾料理体験会)organizer_zh/en 含上田村振興会・普門寺資料,與 raw_description 完全無關。fb12bfa7(台湾茶ゲームイベント / kokuchpro)同一の 上田村振興会・普門寺(AI翻訳) が再発。raw_description は「語学スクール」のみ。organizer_zh/en = null + FC lock で対応。location_name・location_address も kokuchpro 構造フィールド(会場:/住所:)から正値に修正。Rule: DB に保存された timestamp は UTC。Next.js の client component で new Date(dateStr) を使う場合、ブラウザの TZ が UTC でなければ getDate() / getMonth() はローカル時間で評価される。JST(UTC+9)では UTC 15:00 が翌日 00:00 に見えて日付が 1 日ずれる。
修正パターン:
// ✅ 正確:UTC 日付として取り出す
const d = new Date(dateStr)
const day = d.getUTCDate() // getDate() ではなく getUTCDate()
const month = d.getUTCMonth() + 1
const year = d.getUTCFullYear()
// ✅ toLocaleDateString も timeZone: "UTC" 必須
d.toLocaleDateString("ja-JP", { timeZone: "UTC", month: "short", day: "numeric" })
// ❌ 誤り:ブラウザ TZ に依存
const day = d.getDate()
d.toLocaleDateString("ja-JP", { month: "short", day: "numeric" })
対象: client component 内で Date オブジェクトを生成・操作する全コード。SSR(Node.js は UTC 環境)との整合性のために必須。
根本原因: 爬蟲が JST 時間を +00:00 として保存(tzinfo=timezone.utc)しているため、DB の 2026-06-13T00:00:00+00:00 は実際には JST 2026-06-13 09:00(問題なし)だが、scraper によっては深夜値(15:00 UTC = 翌日 00:00 JST)が混入する可能性がある。Client side では常に UTC で読む実装が最も安全。
Reference incident: 2026-05-12 — EventListClient.tsx / MovieWorksList.tsx の getDate() が JST ブラウザで 1 日ずれ → getUTCDate() + { timeZone: "UTC" } に修正。
Rule: 聚合站 scraper(ftip、prtimes、gnews、walkerplus 等)的 source_url 必須永遠保留聚合站自身的 URL。從文章中提取的第一方主辦方 URL 存入 official_url,不可覆寫 source_url。
正確分工:
source_url = 聚合站頁面 URL(https://www.ftip-japan.org/NNN、https://prtimes.jp/...)— 資料溯源憑證official_url = 提取的第一方官方 URL(活動官網、Facebook event 頁、主辦方網站);無法提取時為 None常見提取模式(ftip content 中的 公式サイト 格式):
_OFFICIAL_URL_RE = re.compile(
r"公式サイト\s+(https?://\S+|[\w.-]+\.\w{2,}(?:/\S*)?)",
re.IGNORECASE,
)
def _extract_official_url(self, content: str) -> str | None:
m = _OFFICIAL_URL_RE.search(content)
if not m:
return None
url = m.group(1)
if not url.startswith("http"):
url = "https://" + url
return url.rstrip("。、)")
✅ 正確模式:
source_url = rss_link # 聚合站 URL,永遠保留
official_url = self._extract_official_url(content) # None 是合法值
❌ 反模式(已廢棄):
# 用官方 URL 覆蓋 source_url — 破壞資料溯源 audit trail
source_url = self._extract_official_url(content) or rss_link
Reference incidents:
ab771e2 — ftip.py 首次修正誤以「官方 URL 較有資訊量」讓 source_url 指向 www.taiwanprism.com,破壞 FTIP audit trail7c34788 — 更正為正確模式:source_url=ftip-japan.org/699、official_url=taiwanprism.com,DB 事件 023dcbec 同步修正Rule: 日期字串含 ~(如 8/30~31)表示多日活動,必須同時提取 start_date 和 end_date。
實作範例:
_END_DAY_RE = re.compile(
r"(\d{1,2})/(\d{1,2})~(\d{1,2})(?![/])" # M/D~D — (?![/]) 防止跨月假匹配
)
def _parse_date_range(self, text: str, year: int) -> tuple[date | None, date | None]:
m = _END_DAY_RE.search(text)
if m:
month, start_day, end_day = int(m.group(1)), int(m.group(2)), int(m.group(3))
return date(year, month, start_day), date(year, month, end_day)
return None, None
跨月防護:(?![/]) 確保 ~ 後面不接 /。若接 /(跨月,如 3/10~5/31),跳過 end_date 提取,避免把月份誤解為日數。
正確/錯誤案例:
8/30~31 → start=8/30, end=8/31 ✅3/10~5/31 → (?![/]) 命中,不提取 end_date ✅(正確放棄)~ → 僅提取 start_date,end_date=None ✅Reference incident: 2026-05-10 — ftip.py 事件 023dcbec(台湾光譜 8/30~31)start_date 回退到 RSS pubDate,end_date 未設定(commit ab771e2)。
Rule: 以城市名("東京都"、"大阪"、"京都")作為全國性組織的 location_address fallback 是反模式,必須禁止。
為何危險:
location_address(_ai_or_existing() 只在值為 null 時才詢問 GPT)location_prefectures 也會被錯誤推斷正確做法:
# ✅ 正確:無法確定地址時設 None
location_address = self._extract_venue_address(content) or None
# ❌ 錯誤:以組織所在城市作為全國活動的 fallback
location_address = "東京都" # 全國性組織的活動可能在任何地方
例外(允許硬編碼):scraper 專屬於單一地點的場館(固定影院、固定展覽館)時,可硬編碼該場館地址。
Reference incident: 2026-05-10 — ftip.py location_address = "東京都" 導致台湾光譜(京都活動 〒603-8163 京都府...)被錯誤標為東京(commit ab771e2)。
Rule: note_creators 來源的レポート記事には必ず三つの問題が発生する。検出時は以下の三点を一括修正し、FC 鎖定すること。
三重問題(全件に発生):
start_date = 記事公開日(≠ 活動日):記事が公開された日が自動的に start_date に入り、実際の開催日(1〜数ヶ月前)と異なる。本文中の「〇月〇日開催」「〇月〇日に参加」等から正しい日付を特定すること。location = 主催者の日本拠点:主催者が日本に拠点を持つ場合、annotator がその住所を location として設定する。実際の活動場所(特に台灣で開催の場合)を確認し、location_address / location_prefectures を null に修正。report category 欠如:annotator.py の _REPORT_TRIGGER_RE が自動注入する(commit 1e00933 + d0eb93e 以降)。検知キーワード:レポート・レポ・報告・記録・アーカイブ・recap・行ってきた・観てきた・見てきた・鑑賞レポ・結果発表。注入時 ja 名称がすでに 【...】 で始まる場合は 【レポート】 を重ねない(_inject_report_prefix の二重括弧ガード)。既存 annotated events には Supabase SQL で report を追加後 python annotator.py --backfill-report-prefix を実行。FC 鎖定対象(計 9 項):start_date、location_name、location_address、location_prefectures、name_ja、name_zh、name_en、categories
自動化範囲(annotator.py commit 1e00933 以降):report category 注入 + 三語接頭辭注入は自動処理。start_date / location の修正は human review 必須。
Reference incident: 2026-05-10 — event a7a05be6(台湾薬膳文化体験レポート)start_date=2026-05-08(記事日)→ 修正後 2026-04-21(活動日);location_name=台湾華語文学習センター大阪弁天町(主催者拠点)→ 修正後 台北医学大学(活動場所、台灣)。
& in RSS link text before regex: RSS <link> text nodes may contain HTML-entity-encoded & even after XML parsing (double-encoded by the origin server). item.find("link").text can return "...?uid=4&pid=103701". Any regex on the raw text will miss &pid=. Always normalize: link = link_raw.replace("&", "&") before extraction. Apply to both source_url construction and any _extract_pid()-style function. (Incident: rti_jp 0 events, 2026-05-14.)is not None: if element: on an xml.etree.ElementTree Element is always True (DeprecationWarning since Python 3.8). Write element is not None and element.text instead.STALE_DAYS guard for podcast/RSS scrapers: For scrapers that loop over a curated list of program IDs, check the latest episode's pubDate before iterating all items. If it exceeds STALE_DAYS (e.g. 90), the program is discontinued — skip to avoid a full-page fetch that returns 0 events:
STALE_DAYS = 90
latest_pub = _parse_pubdate(items[0].find("pubDate").text)
if latest_pub and (now - latest_pub).days > STALE_DAYS:
logger.info("rti_jp: id=%s latest episode %dd old — discontinued", pid, age)
continue
LOOKBACK_DAYS must match broadcast frequency: Weekly programs → 14d is sufficient. Monthly programs → 60d minimum. If a program's cadence is unknown, default to 60d.BLOCKED_ORGANIZER_PATTERNS in peatix.py — always check before adding new title-based blocks.台東 in TAIWAN_KEYWORDS can match the Tokyo ward 台東区. Use _TAIWAN_KW_NO_TAITO guard list.LOCATION / 場所 and DATE AND TIME / 日時 blocks before CSS fallback. Detect Online event / オンライン before any physical address fallback, reject generic address candidates (Japan, 東京都, ward-only fragments), and write business_hours from the text block or body labels such as 時間:14:00-15:30..ticket-price or [class*='price'] returns only a generic label (料金, 参加費, チケット, etc.), treat it as empty and read body lines that start with a known price label plus a price/free signal. Do not extract unlabeled amounts from descriptive body copy.page.goto(): Peatix redirects to /us/event/{id} or /jp/event/{id} depending on browser locale. Strip the prefix in both the collection stage (_search_events/_scrape_group_events) AND _scrape_detail() so source_url is always https://peatix.com/event/{id}.
(Incidents: peatix e9c6f80b 2026-05-17, 55d766ae 2026-05-19.)inner_text() length guard for short-string fields: Playwright inner_text() on a group anchor or any interactive DOM element can return the entire page content (e.g. starting with "Translate this page...") instead of just the element text. Always add a length guard for fields expected to be short:if _txt and len(_txt) <= 100organizer_name, performer, location_name etc. Apply to both primary extraction path and any fallback path. The threshold should be ≈2–3× the expected maximum (organizer/location names: ≤ 100). (Incident: peatix organizer f839508, 2026-05-19.)Event(). Forgetting to include it in the constructor causes the field to always be null in DB — silent failure, no error. Before submitting a PR, cross-check all extracted variables against Event() constructor arguments. Pattern: organizer=organizer_name or None. (Incident: peatix organizer_name always null — commit 24198d0, 2026-05-19.)peatix.com/search?q=...)_ORGANIZERS — never remove; serves as backup if DB changes_load_db_organizers() — queries research_sources WHERE agent_category='peatix_organizer' AND status='implemented'agent_category (e.g. peatix_organizer). Do NOT reuse note_creator or generic names._scrape_group_events() fetches peatix.com/group/{group_id}/events; group_id extracted from source_profile.group_id or URL path.python discovery_accounts.py --dry-run --slot 3 to verify Peatix slot without DB writes.networkidle 発火後も .event-description 内容が数十ms 遅延してレンダリングされる場合があり、description_ja が None → raw_description が日付 prefix のみになる。
raw_description の長さが 50 字未満かつ内容が 開催日時: のみ → 「汚薄 raw_description」と判定。raw_description パッチ → annotation_status = 'pending' → annotator.py --source-ids <source_id> で手動実行。ee17c509 2026-05-30)organizer(主催者)。イベントを実施するアーティスト/動作者 = performer。GPT が両者を入れ替える可能性があるので、日本語淡化語(「夫婦」「ユニット」など一般名詞)が performer に入っていたら手修正 + FC lock。(Incident: peatix ee17c509 performer='夫婦' → 'Floti Studio' 2026-05-30)performers 一般名詞ガード: performers に「夫婦」「カップル」「グループ」等の一般名詞が入っていたらアーティストの固有名(ユニット名・人名)に修正 + FC lock が必須。annotator は raw_description のカタカナ/漢字をそのまま取り込むため、一般名詞を固有名詞と誤認することがある。(Incident: peatix ee17c509 performers=['夫婦'] → ['Floti Studio'] 2026-05-30)performer_zh も performer_en も同じ値を設定。GPT は英語名を繁体中文に翻訳しようとして null を返すことがある。手動補完 + FC lock が必要。(Incident: peatix ee17c509 2026-05-30)organizer 値が英語固定商標の場合、organizer_zh/en も翻訳不要—同じ値を FC lock。翻訳しようとした GPT が (AI翻譯) マーカー付きで格納する場合もある。判定基準: organizer 値の文字が全て ASCII / アルファベット → organizer_zh/en = organizer。(Incident: iwafu a4442567 QUEEN SHOP 2026-05-31)source_url の場合、SNS リンクの設定先は次のルールに従う:
performer_url(単一演者)または performer_urls[](複数演者、インデックスは performers[] と対応)organizer_urlofficial_url = 専用イベントページ URL のみ。SNS を official_url に設定するのは非推奨(performer_url / organizer_url を優先)。専用ページが存在しない場合は official_url = null。performer_url 新設(migration 078、2026-05-30)以前は演者 Instagram を official_url に設定していたが、現在は performer_url に設定し official_url は null のままにする。ee17c509 旧: official_url='https://www.instagram.com/flotistudio/' → 新: performer_url='https://www.instagram.com/flotistudio/' + official_url=null 2026-05-30)台湾など世界各地 / 全国各地.*台湾 etc., the event is a nationwide/global tour where Taiwan is just one stop. Reject it — it is NOT a Taiwan-themed event. The _GLOBAL_TOUR_PATTERNS regex in iwafu.py implements this guard.リアル脱出ゲーム×名探偵コナン) must be blocked by _BLOCKED_TITLE_PATTERNS in _scrape_detail before the page load — this catches all tour stops as new source_ids appear. Add new entries here when a series is confirmed non-Taiwan-themed.名探偵コナン), add the IP name to _BLOCKED_SERIES. Checked on BOTH card title (pre-load, fast-reject) AND h1 title (post-load). Card titles from search results can be truncated, so the pre-load check alone is not sufficient.organizer_url enrichment: iwafu ソースページは主催者の公式サイト URL を常に含むわけではない。ブランド商標の場合は raw_description 内の extract_first_party_url() で抽出を試みるか、公式サイトを手動で FC 設定すること。organizer_url を設定すると UI に「主催者名 ↗」リンクが自動表示される。(Incident: a4442567 QUEEN SHOP 2026-05-31)business_hours: デパート・ショッピングモールの定常営業時間はイベント固有ではなく venue-level データ。venues テーブルに business_hours カラムが存在しないため、現状は手動 FC 修正で対応。将来的には venues.business_hours カラム(migration 必要)+ enrich_location.py から自動取得する設計。UI 側は event.business_hours 存在時の表示に既対応済み。
a4442567 ルミネエスト 11:00〜22:30 と推測設定 → 実際は平日 11:00〜21:00 / 土日祝 10:30〜21:00 だった)。ilike("raw_title", "%keyword%") to find existing records that should also be deactivated. The filter only prevents future inserts.table.delete().eq("id", eid)) rather than just deactivating. Deactivated events remain accessible via direct URL unless the event page also checks is_active.場所[::]\s*(.+?)(?:\n|交通手段|Q&A|https?://|$) in main_text.
location_name = the venue name captured from 場所:.location_address = search the surrounding text with _ADDR_RE (matches 〒 or prefecture+city+street pattern). If a real address is found AND it differs from the venue name, use it; otherwise set None. NEVER set location_address = location_name — identical values are flagged by auto_qa_address_is_venue_name and violate the Sub-Venue Parent Address Rule (sub-spaces like SC 広場 need the parent building's address, not the sub-space name).card.prefecture for location_address only when the 場所: label is absent. Never store bare prefecture names (e.g. "東京") as the address.For scrapers on live houses / venue sites (e.g. moonromantic), the site publishes both public event listings and internal venue-management posts (rental announcements, wedding inquiries, system maintenance). These are NEVER Taiwan-related.
_BLOCKED_POST_PATTERNS at the module level for known management post types.page_text first-line (fast-reject) and once on the confirmed title (second-pass, catches nav-renders-first edge cases).RENTAL / PRIVATE RENTAL / RENT (venue hire)場所貸し / 会場貸し (Japanese venue rental)WEDDING (if venue offers wedding venue hire)_BLOCKED_POST_PATTERNS = re.compile(
r"\bRENTAL\b|PRIVATE\s+RENTAL|\bRENT\b|場所貸し|会場貸し",
re.IGNORECASE,
)
source_id = eiga_com_{movie_id}_{theater_id}. Each daily run upserts and updates end_date to the last date in the current week's schedule./movie/{id}/theater/ → area links /movie-area/{id}/{pref}/{area}/ → div.movie-schedule[data-theater] + .more-schedule a.icon.arrow → /movie-theater/{id}/{pref}/{area}/{theater_id}/ (address).a.icon.arrow is the all-schedule link: The .more-schedule div has 3 links — copy (/mail/), print (/print/), all-schedule (bare /{theater_id}/). Always use a.icon.arrow; the first a[href*='/movie-theater/'] is the /mail/ link.table.theater-table th:contains("住所") + td on the theater page. Call a_tag.decompose() on all <a> children before get_text() to strip "映画館公式ページ". Never use page-wide address regex — JS code can contain 東京都 fragments.source_id = eiga_com_{movie_id} and location_name=None.任何 scraper 遇到「薄內容指引文」時,應主動抓取外部 ref URL 補充 raw_description。
偵測條件(兩者同時成立):
len(body_text) < N(各 scraper 自行設定閾值,koryu 使用 600 chars)處理流程:
fetch_ref_text(ref_url) — 已在 base.py 實作,直接 import 使用[参照ページ ({ref_url})]:\n{ref_text}date_prefix 改用 記事投稿日: 而非 開催日時: — 避免 GPT 把文章發布日當活動日薄內容指引文的典型特徵:
通用函數:from .base import fetch_ref_text
fetch_ref_text(url, max_chars=3000) — requests + BeautifulSoup,selector 優先序:main > article > bodyNone 表示失敗或內容 < 200 charsAnnotator-side thin content detection(annotator.py):
not start_date OR len(raw_description) < 400 chars_GNEWS_THIN_DESC_CHARS = 400_gnews_needs_article_fetch(e) — gnews_needs_fetch 計數與 per-event trigger 統一使用此函數google_news_rss(使用 Playwright 跟進 Google News redirect)| Source | Scraper-side fix | Annotator-side fallback | Reason |
|---|---|---|---|
| google_news_rss | pub_date anchor + Playwright (in annotator) | ✅ _GNEWS_THIN_DESC_CHARS=400, Playwright | RSS snippet only; Google redirect URL |
| koryu | fetch_ref_text() for pointer articles | — (scraper handles) | Pointer articles < 600 chars + external URL |
| nhk_rss | pub_date anchor + fetch_ref_text(article_url) | ✅ _NHK_THIN_CHARS=400, requests-based | RSS snippet always < 200 chars |
| doorkeeper | N/A | N/A | API returns full description |
| connpass | N/A | N/A | API returns full description |
| iwafu | N/A | N/A | Visits detail page |
| arukikata | N/A | N/A | Fetches full article (BS4) |
| peatix | N/A | N/A | Visits detail page (Playwright) |
| taiwan_cultural_center | N/A | N/A | Visits detail page (Playwright) |
| hakusuisha | skip_tags HTMLParser + 8000 char limit in _fetch_detail_text_fallback() | ✅ thin-content rescue when source_name == "hakusuisha" and 日時 absent in raw_description | JS/nav content consuming 2000-char budget; ■日時: typically appears after char 2000; raised from 4000→8000 (commit a0292a2) |
| taioan_dokyokai | N/A | N/A | Visits detail page (Playwright) |
| taiwan_kyokai | N/A | N/A | Visits detail page (Playwright) |
| taiwan_festival_tokyo | N/A | N/A | Structured widget, not article-based |
| ide_jetro | N/A | N/A | date_prefix fix sufficient |
Rule: Only apply thin-content detection when the scraper stores a snippet (RSS/headline) and the full content lives at a separate URL. API-based and detail-page-visiting scrapers are inherently complete.
auto_generate が生成した FIELD_SELECTORS["date"] が指すセレクタは、記事公開日(publication date) を指している場合がある。プロモーション審査時に必ず確認すること。
FIELD_SELECTORS["date"] のセレクタが何を取得するか確認する(span.note、.date、time[datetime] 等)。YYYY.MM.DD 形式は公開日の可能性が高い)。日時: / 開催日時: ラベルに存在する場合は、listing page の selector を使わずに detail page から抽出すること。# _extract_event_dates(detail_text, card_year) パターン(hakusuisha.py を参照)
# 対応パターン1: 2026年3月20日・21日(同月 ・ 複数日)
# 対応パターン2: 2025年11月23日.../24日(同月 / 複数日)
# 対応パターン3: 2026年1月10日 / 2026年1月11日(完全日付2つ)
raw_description プレフィックスルール日時 ラベルあり → 開催日時: YYYY年MM月DD日 プレフィックス(GPT annotator への明確なシグナル)日時 ラベルなし(公告・お知らせ文)→ (記事投稿日: YYYY年MM月DD日) 年号アンカーauto_generate で生成されたすべての scraper のプロモーション前に FIELD_SELECTORS["date"] を人工審査すること。Playwright ベースのスクレーパー(detail page を訪問する)でも、listing page の date selector が残っている場合は要確認。
Reference incident: 2026-05-04 hakusuisha FIELD_SELECTORS["date"] = "span.note" → 記事公開日を取得(活動日ではない)(commit b3708e1)。
Rule: auto-generated scraper の body.inner_text() および HTTP fallback のスライス上限は 最低 8000 字元 を使うこと。4000 字元では nav / header ノイズに予算を消費され、イベント本文の ■日時: / 会場: / 主催: が切り捨てられる。
# ❌ 4000 は nav のある site では不足
full_description = body.inner_text()[:4000]
# ✅ 8000 以上を推奨
full_description = body.inner_text()[:8000]
Incident: hakusuisha.py — nav menu が 2000+ 字消費し、■日時: を截斷点の外に押し出した(commit a0292a2)。
Rule: scraper が raw_description 先頭に 開催日時: YYYY年MM月DD日 等のプレフィックスを追加する前に、日時/会場/主催の regex 抽出を完了させること。
問題:_JITSU_RE = re.compile(r"日時[::]") は 開催日時: にもマッチする。scraper が自己注入したプレフィックスを先に検索すると、group(1) がプレフィックスの日付テキスト(時間なし)になり、後続の _TIME_RE が正文の時間 (HH:MM〜HH:MM) を永遠に見つけられなくなる。
# ❌ Bad: プレフィックスを先に加えてから検索
raw_description = f"開催日時: {date_str}\n\n{full_description}"
m = _JITSU_RE.search(raw_description) # → マッチするのはプレフィックスの "開催日時:"
# ✅ Good: 検索を先に行い、プレフィックスは最後に加える
m = _JITSU_RE.search(full_description) # または _TIME_RE で直接時間を検索
# ... 抽出完了後 ...
raw_description = f"開催日時: {date_str}\n\n{full_description}"
代替解法:日時[::] の代わりに [■◆●▼]\s*日時[::] で検索(行頭の記号を要求)するか、_TIME_RE(\d{1,2}:\d{2} を含む時刻パターン)で full_description を直接検索して前綴を回避する。
hakusuisha.py — _JITSU_RE.search() が 開催日時: 2026年4月26日 プレフィックスにマッチし、_TIME_RE が正文の 14:00〜16:00 を見つけられなかった(commit a0292a2)。FIELD_SELECTORS["date"] が正確なイベント開催日を返すか検証する手順:
日時: / 開催日: / 期間: ラベルがあればそちらを使う_JITSU_RE = re.compile(r"[■◆●▼]?\s*日時[::]\s*(.{5,150})", re.MULTILINE)_FULL_YMD_RE = re.compile(r"(\d{4})年(\d{1,2})月(\d{1,2})日")_END_DAY_RE = re.compile(r"[・//]\s*(\d{1,2})日")scraper/sources/hakusuisha.py の _extract_event_dates()end_date も同時に設定すること(「・DD日」パターンで終了日が取れる場合が多い)日本學術研討會的 location_name 有時包含完整郵遞區號地址於括號中:
南山大学 Q棟103教室 (〒466-8673 名古屋市昭和区山里町18)
scraper 的 _extract_venue() 應優先識別 [((](〒\d{3}-\d{4}...) 模式,提取為 location_address 並從 location_name 中去除括號部分。
已實作:scraper/sources/taiwanshi.py 作為參考實作。
note.com RSS <description> 約在 140 字截斷,末尾可能為「続きをみる」(endswith,非 equals)。
當 _is_truncated(plain_desc) 為 True 時,_parse_item() 呼叫 _fetch_article_content() 取得全文(JSON-LD articleBody / description / BeautifulSoup p fallback)。
已實作:scraper/sources/note_creators.py → _fetch_article_content() helper。
無需 Playwright,標準 requests.get() 即可。
Note Creator Source Guard(二手聚合源截斷修復模式)
...続きをみる 結尾(endswith,非 equals)→ 舊 truncation guard plain_desc in ("続きをみる","") 漏判 → _fetch_article_content 永不呼叫_auto_lock_location 自動 FC 鎖定錯誤地點_is_truncated(text): text.endswith("続きをみる") or len(text) < _NOTE_THIN_CHARS(120)extract_first_party_url(body, exclude_hosts) in base.py(共用): 從全文萃取 official URL,排除報名平台(peatix/forms.gle/google/linktr.ee);優先「🔗詳細/申込/公式」標記附近 URLofficial_url 為外部機構域(非報名平台)→ 設 effective_location_name=None 抑制投稿者 metadata location,讓 _auto_lock_location 跳過上鎖(if not event.location_name: continue)tw_insecure_domain(url) in base.py(共用): host endswith .edu.tw/.gov.tw → True(fetch_ref_text verify=False)fetch_ref_text(url, verify_ssl=True) in base.py: 新增 verify_ssl 參數;既有 caller 預設 True,行為不變.edu.tw/.gov.tw 白名單域annotator 的 LOCATION GATE(SYSTEM_PROMPT)是給 GPT 的指示文字,NOT Python code。
主事件 update_data 完全不含 is_active——re-annotate 不會停用主事件。
LOCATION GATE 僅影響 selection_reason 品質。正確設定 study_abroad/tourism 等 category 是資料正確性問題,非「過 gate 求存活」。
note.com creator 追加手順(2 ステップ必須):
CREATOR_META に {"slug": "<creator_slug>", "category": "<category>", "location": "<prefecture>"} を追加research_sources の対応行を status=implemented に更新(scraper_source_name = 'note_creators' も確認)
事前確認:python main.py --dry-run --source note_creators 2>&1 | grep "events found" で件数増加を確認してから commit。enrich_location.py 使用 GPT 補充缺少的地址。接收 GPT 回傳後,寫入 DB 前必須套用以下 guard:
if addr.strip() == venue_name.strip(): skip + log warning。address == venue_name 是提取失敗的確定標誌(GPT 只是複製了場地名)。○○ビル2階、○○カフェ(寺内))需用親設施的地址,不得用子場地名作為 address。location_name:query 必須 SELECT location_name,才能執行 identical address guard。# enrich_location.py — 寫入前 guard 範例
if location_address and location_address.strip() == (location_name or "").strip():
logger.warning("SKIP: address == venue_name for event %s (%s)", eid, location_address)
continue
Reference incident: 2026-05-05 — GPT extracted 仙六屋カフェ from 会場:仙六屋カフェ as address → identical guard added (commit 628e3e7).
_extract_location_address() searches for 所在地/住所 sections. When absent (common for 後援-type posts), set location_address = None — do NOT fall back to the venue name. The annotator will fill the address via PARENT VENUE ADDRESS RULE. Old pattern or (venue if venue else None) was removed (commit 9d6e0fc) because it echoed venue name as address, blocking annotator correction.main_text will be a redirect message with no venue section. _extract_venue returns None, so location_address is also None. This is acceptable — the event is stale.end_date = start_date at the end of _extract_event_fields.2026年4月20日) before the actual event content. Do NOT rely solely on the generic YYYY年MM月DD日 fallback — it will pick up the publish date if no structured 日時: field exists.5月16日(土) (with day-of-week) are actual event dates. Extract these BEFORE the generic fallback, then infer the year from the nearest 20XX年 in the text.